Skip to content

FUN-6 API-First Design

Pre-Discussion

1. Introduction

This FUN documents the decision to adopt an API-first design approach for Fundament. In API-first development, the API specification is written before implementing any code. The specification becomes the contract that drives both backend implementation and client generation.

2. Why API-First

2.1. Multiple Clients, Single Source of Truth

Fundament needs to support multiple clients:

  • Console UI (web interface)

  • CLI tooling

  • Infrastructure-as-code integrations

  • Third-party integrations

Without a well-defined API contract, each client would interpret the backend differently, leading to inconsistencies and integration bugs. By defining the API first, all clients work from the same specification.

2.2. Developer Experience

API-first enables:

  • Generated client libraries in multiple languages

  • Auto-generated documentation

  • Contract-based testing

  • Parallel development (frontend and backend can work simultaneously)

2.3. Change Management

When the API specification is the source of truth:

  • Breaking changes are explicit and visible in the spec diff

  • Versioning is enforced at the API level

  • Deprecation can be communicated through the spec

3. Technology Choices

3.1. Protocol Buffers and gRPC

For service-to-service communication and the primary API definition, we use Protocol Buffers (protobuf) with gRPC:

  • Strong typing with code generation

  • Efficient binary serialization

  • Built-in versioning through protobuf evolution rules

  • gRPC-Gateway for REST/JSON translation

3.2. OpenAPI for HTTP APIs

Where HTTP/REST is more appropriate (authentication, webhooks), OpenAPI specifications are used:

  • Wide tooling ecosystem

  • Human-readable documentation

  • Client generation for languages without good gRPC support

4. Workflow

  1. Design the API - Write the protobuf or OpenAPI specification

  2. Review the specification - API changes go through PR review

  3. Generate code - Use go generate ./…​ or just generate to produce server stubs and client libraries

  4. Implement - Write the business logic against the generated interfaces

  5. Test - Contract tests verify the implementation matches the spec

5. Implications

5.1. No Hand-Written API Code

Generated code must not be manually edited. All API types, server interfaces, and client code come from the specification. This ensures the spec and implementation cannot drift.

5.2. Specification Lives in Version Control

API specifications are tracked in git alongside the code. This provides:

  • History of API evolution

  • Code review for API changes

  • Single source of truth

5.3. Breaking Changes Require Consideration

Because the API is a contract with multiple clients, breaking changes must be:

  • Discussed before implementation

  • Versioned appropriately (new version or deprecation period)

  • Communicated to API consumers

6. Current API Design

This section documents the API specifications in Fundament.

6.1. API Overview

Fundament exposes two primary APIs:

API Type Location Purpose

authn-api

gRPC + OpenAPI

authn-api/pkg/proto/ + authn-api/openapi.yaml

Authentication, user management

organization-api

gRPC

organization-api/pkg/proto/v1/

Organizations, clusters, node pools, plugins

All gRPC APIs use Connect RPC for HTTP/1.1 compatibility.

6.2. Design Conventions

6.2.1. Naming Conventions

RPC Methods

RPC methods follow the <Action><Object> pattern (PascalCase):

Action Description Example

List

Retrieve a collection of resources

ListClusters, ListNodePools

Get

Retrieve a single resource by ID

GetCluster, GetNodePool

Create

Create a new resource

CreateCluster, CreateNodePool

Update

Modify an existing resource

UpdateCluster, UpdateNodePool

Delete

Remove a resource (soft delete)

DeleteCluster, DeleteNodePool

Add

Add a relationship or child resource

AddInstall

Remove

Remove a relationship or child resource

RemoveInstall

Messages

Messages follow consistent naming patterns:

  • Request messages: <Action><Object>Request (e.g., GetClusterRequest, CreateNodePoolRequest)

  • Response messages: <Action><Object>Response (e.g., GetClusterResponse, ListClustersResponse)

  • Void responses: Use google.protobuf.Empty for operations that return no data (e.g., Update, Delete, Remove)

  • Resource messages: Singular noun (e.g., Cluster, NodePool, Organization, Install)

  • Summary vs Detail: Use <Object>Summary for list items and <Object>Details for full resource (e.g., ClusterSummary, ClusterDetails)

Enums
  • Prefix: Enum values are prefixed with the enum name in SCREAMING_SNAKE_CASE

  • Zero value: Always include an UNSPECIFIED zero value

  • Example: ClusterStatusCLUSTER_STATUS_UNSPECIFIED, CLUSTER_STATUS_RUNNING

Fields
  • IDs: Use <object>_id for foreign key references (e.g., cluster_id, node_pool_id, plugin_id)

  • Timestamps: Use past tense for timestamp fields (e.g., created, deleted, revoked)

  • snake_case: All field names use snake_case (e.g., kubernetes_version, machine_type, autoscale_min)

Identifiers (UUIDv7)

All resource identifiers use UUIDv7. This format embeds a Unix timestamp in the first 48 bits, providing:

  • Time-ordered: IDs sort chronologically, improving database index locality and query performance

  • Globally unique: No coordination required between services

  • Timestamp extraction: Creation time can be derived from the ID without additional fields

  • K-sortable: Lexicographic sorting matches chronological order

The database generates UUIDv7 values using DEFAULT uuidv7() on primary key columns.

6.2.2. Protocol Buffers

  • Package naming: <domain>.v1 (e.g., organization.v1, authn.v1)

  • Field numbering: Start at 10, increment by 10 for room to insert fields

  • Optional fields: Use optional keyword for nullable values

  • Empty requests/responses: Use google.protobuf.Empty for void operations

6.2.3. OpenAPI

  • Version: OpenAPI 3.0.3

  • Responses: Standard error responses (BadRequest, Unauthorized, InternalServerError)

  • Schemas: Reusable components in #/components/schemas

6.2.4. Code Generation

Generate code using:

just generate
# or
go generate ./...

Generated code locations:

  • Proto → pkg/proto/gen/

  • OpenAPI → pkg/*/server.gen.go (via oapi-codegen)

6.3. Data Patterns

6.3.1. Soft Deletes

Resources are never physically deleted from the database. Instead, a deleted timestamp column marks when a resource was removed:

  • deleted IS NULL indicates an active resource

  • deleted IS NOT NULL indicates a deleted resource

This pattern provides:

  • Audit trail: History of all resources is preserved

  • Recovery: Deleted resources can be restored

  • Referential integrity: Foreign key relationships remain valid

Unique constraints use NULLS NOT DISTINCT to allow multiple deleted resources with the same name while ensuring active resources have unique names:

CONSTRAINT clusters_uq_name UNIQUE NULLS NOT DISTINCT (organization_id, name, deleted)

6.3.2. Multi-tenancy

Fundament uses PostgreSQL Row-Level Security (RLS) to enforce organization isolation at the database level. Each request sets the current organization context:

SET app.current_organization_id = '<uuid>';

RLS policies automatically filter queries to only return rows belonging to the current organization:

CREATE POLICY organization_isolation ON tenant.clusters
  FOR ALL TO fun_fundament_api
  USING (organization_id = current_setting('app.current_organization_id')::uuid);

This approach ensures:

  • Defense in depth: Tenant isolation enforced at database level, not just application code

  • No accidental leaks: Impossible to query another organization’s data without explicit context change

  • Simplified queries: Application code doesn’t need to add organization filters

Child resources (e.g., node_pools, installs) use policies that join to their parent to inherit organization isolation:

CREATE POLICY node_pools_organization_policy ON tenant.node_pools
  USING (EXISTS (
    SELECT 1 FROM clusters
    WHERE clusters.id = node_pools.cluster_id
    AND clusters.organization_id = current_setting('app.current_organization_id')::uuid
  ));

6.3.3. Partial Updates

Update operations use the optional keyword in protobuf to support partial updates. Only fields that are explicitly set in the request are modified:

message UpdateClusterRequest {
  string cluster_id = 10;
  optional string kubernetes_version = 20;
}

This pattern:

  • Avoids field masks: Simpler than Google’s FieldMask pattern for most use cases

  • Explicit intent: optional makes it clear which fields can be omitted

  • Null safety: Unset fields are distinguishable from empty values in generated Go code (*string vs string)