Documentation

ADR 009: API Architecture & Contract Strategy

Decision to use OpenAPI-first approach with hey-api for client generation, REST-first approach, and AI/MCP integration support.

Context

We need to define API contract strategy for frontend-backend communication that:

  • Provides type-safe client generation for frontend consumption
  • Enables better separation of concerns between API contracts and implementation
  • Supports AI agent integration via MCP (Model Context Protocol)
  • Chooses primary API interface pattern (REST vs GraphQL)
  • Works with our Fastify backend and Next.js frontend
  • Maintains portability goals (no framework lock-in)

Considered Options

Option A – OpenAPI-First with hey-api + REST-first (Chosen)

OpenAPI spec as source of truth, hey-api for TypeScript client generation, REST as primary interface, GraphQL as optional secondary.

Pros

  • OpenAPI standard: Industry-standard format for API contracts, works with all tools
  • Type-safe clients: hey-api generates fully typed TypeScript clients from OpenAPI specs
  • Separation of concerns: OpenAPI specs live separately from implementation, better architecture
  • Automatic client generation: Frontend gets fully typed clients automatically via code generation
  • REST-first for simplicity: RESTful interfaces are simple, cacheable, and well-understood
  • AI/MCP integration: REST endpoints with OpenAPI specs work seamlessly with Model Context Protocol servers and AI agents
  • Framework portability: OpenAPI works with Fastify, Express, or any framework - no lock-in
  • Interactive API docs: Scalar UI can be served for interactive API exploration
  • GraphQL as option: Can expose GraphQL layer if complex querying needed, but REST is primary
  • Complements stack: Works with Fastify's plugin architecture and our portability goals
  • Multi-language support: OpenAPI specs can generate clients in Python, Go, Rust, etc. using standard tools

Cons

  • Need to maintain OpenAPI spec separately
  • Requires code generation step (but hey-api makes this straightforward)
  • Less flexible than GraphQL for complex queries (but can add GraphQL layer)

Option B – Contract-Based with ts-rest (Rejected)

ts-rest for type-safe contracts, REST as primary interface.

Pros

  • Type-safe contracts with clear contract definitions
  • Zero codegen for TypeScript consumers

Cons

  • Convoluted and overcomplicated in experimentation
  • Didn't work well in practice
  • Less standard than OpenAPI
  • Harder to generate clients for other languages

Option C – Framework-Based (tRPC)

End-to-end type safety with tRPC.

Pros

  • End-to-end type safety, no contract definitions needed
  • Automatic type inference

Cons

  • Framework lock-in (requires specific server/client setup)
  • Less AI/MCP friendly (RPC pattern vs REST)
  • Harder to migrate between frameworks
  • Not standard HTTP REST, requires tRPC client
  • Less portable across frameworks

Option D – GraphQL-First

GraphQL as primary API interface.

Pros

  • Flexible querying, strong typing with GraphQL schema
  • Prevents over-fetching

Cons

  • More complex than REST for simple CRUD
  • Harder caching strategy
  • Less MCP/AI friendly (agents prefer simple REST)
  • Requires GraphQL client setup
  • Over-fetching prevention comes at cost of complexity
  • Less standard HTTP caching

Option E – Plain REST (no contracts)

Standard HTTP REST without contract definitions.

Pros

  • Simple, standard HTTP
  • No additional tooling needed

Cons

  • No type safety between frontend and backend
  • Manual client code
  • No automatic API documentation
  • No OpenAPI generation

Decision

We will use OpenAPI-first approach with hey-api for TypeScript client generation and REST-first approach, with OpenAPI specs serving as the source of truth for documentation and AI/MCP integration.

TLDR: Comparison Table

FeatureOpenAPI + hey-api ✅tRPCGraphQL-FirstPlain REST
Type safety✅ Generated from OpenAPI✅ End-to-end✅ Schema-based❌ Manual
Client generation✅ Automatic (hey-api)✅ Automatic✅ Codegen❌ Manual
AI/MCP integration✅ REST-native⚠️ RPC pattern⚠️ Complex✅ Standard HTTP
Framework portability✅ Any framework❌ tRPC-specific✅ Any GraphQL server✅ Any framework
Separation of concerns✅ Clear OpenAPI spec⚠️ Coupled✅ Schema separate❌ No contracts
OpenAPI standard✅ Native⚠️ Via plugin⚠️ Via conversion❌ Manual
Caching✅ HTTP caching⚠️ Custom⚠️ Complex✅ HTTP caching
Learning curve✅ REST + OpenAPI⚠️ tRPC patterns⚠️ GraphQL✅ Simple
Works with Fastify✅ Native support⚠️ Adapter needed✅ Via plugin✅ Native

Main reasons:

  • OpenAPI standard: Industry-standard format, works with all tools and generators
  • Type-safe clients: hey-api generates fully typed TypeScript clients from OpenAPI specs
  • Separation of concerns: OpenAPI specs live separately from implementation, better architecture
  • Automatic client generation: Frontend gets fully typed clients automatically via code generation
  • REST-first for simplicity: RESTful interfaces are simple, cacheable, and well-understood
  • AI/MCP integration: REST endpoints with OpenAPI specs work seamlessly with Model Context Protocol servers and AI agents
  • Framework portability: OpenAPI works with Fastify, Express, or any framework - no lock-in
  • Interactive API docs: Scalar UI can be served for interactive API exploration
  • GraphQL as option: Can expose GraphQL layer if complex querying needed, but REST is primary
  • Complements stack: Works with Fastify's plugin architecture and our portability goals
  • Multi-language support: Standard OpenAPI generators work for Python, Go, Rust, etc.

Implementation Details

OpenAPI Specification

OpenAPI specs are defined in apps/api/openapi/openapi.json:

{
  "openapi": "3.0.0",
  "paths": {
    "/users/{id}": {
      "get": {
        "summary": "Get user by id",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "User found",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": { "type": "string" },
                    "email": { "type": "string" }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Client Generation

TypeScript clients are generated using @hey-api/openapi-ts:

  • Generated code in packages/core/src/gen/ and packages/react/src/gen/
  • Fully typed API clients
  • React Query hooks generated automatically
  • Type-safe error handling

Server Implementation

Server implements routes that match the OpenAPI spec:

  • Fastify routes manually implemented to match OpenAPI spec
  • Type-safe request/response handling via generated types
  • Validation can use Zod schemas derived from OpenAPI

Client Consumption

Frontend consumes the API via generated clients:

  • Fully typed API clients from @basilic/core
  • Generated React Query hooks from @basilic/react
  • Type-safe error handling

OpenAPI Integration

  • OpenAPI 3.0 specs: Source of truth for API contracts
  • Programmatic access: Served at /reference/openapi.json
  • Interactive documentation: Scalar UI at /reference
  • MCP integration: OpenAPI specs enable AI agents to interact with API via Model Context Protocol
  • Multi-language clients: OpenAPI specs can generate clients in Python, Go, Rust, etc. using standard generators

Multi-Language SDK Strategy

The Clean Mental Model

We use an OpenAPI-first strategy that works for all languages:

  • OpenAPI spec is the source of truth for all API contracts
  • hey-api generates TypeScript clients from OpenAPI specs
  • Standard generators create clients for other languages (Go/Rust/Python/etc.)

This approach is consistent across all languages - OpenAPI is the universal contract.

Philosophy: OpenAPI-First with Generated Clients

Keep OpenAPI as the source of truth, generate clients for all languages.

This gives us:

  • OpenAPI spec always up-to-date - Single source of truth
  • Fully typed TS client - Generated by hey-api from OpenAPI spec
  • Consistent across languages - Same OpenAPI spec generates all clients
  • Standard tooling - Works with industry-standard OpenAPI generators

Why Codegen for TypeScript?

TypeScript consumers get full type safety from generated clients:

  • Automatic type updates - Regenerate when OpenAPI spec changes
  • Excellent IDE experience - Full type inference from OpenAPI schemas
  • Consistent with other languages - Same generation approach
  • Monorepo-friendly - Generated code in packages, easy to share

When to Generate SDKs for Other Languages

For non-TypeScript clients, code generation is the standard approach:

Option 1: No SDK (Default for Early Stage)

  • Just call REST endpoints with a small hand-written client
  • Use the /openapi.json spec for reference
  • Good for: simple integrations, prototyping, low request volume

Option 2: Generate SDK (When It's Worth It)

  • Generate from /openapi.json using language-specific generators
  • Keep generated SDKs in separate repos/packages (not in main monorepo)
  • Good for: complex integrations, high usage, external consumers
  • Popular generators:
    • Go: oapi-codegen, openapi-generator
    • Rust: openapi-generator, progenitor
    • Python: openapi-generator, datamodel-code-generator

Practical Implementation

1. TypeScript Consumers (Primary)

// Generated client from OpenAPI spec
import { createApi } from '@basilic/core'
import { useHealthCheck } from '@basilic/react'

const api = createApi({ baseUrl: API_URL })
// Fully typed, generated by hey-api

2. OpenAPI Spec (Source of Truth)

// apps/api/openapi/openapi.json
{
  "openapi": "3.0.0",
  "paths": { /* ... */ }
}

3. Client Generation

# Generate TypeScript clients
pnpm --filter @basilic/core gen
pnpm --filter @basilic/react gen

4. Other Language Consumers (Optional)

# Generate Go client from OpenAPI spec
curl https://api.your-domain.com/reference/openapi.json > openapi.json
oapi-codegen -package client openapi.json > client.go

Migration Path and Flexibility

This architecture doesn't lock you in:

  • OpenAPI is standard - Works with all tools and generators
  • Easy to change generators - Switch from hey-api to another if needed
  • Consistent approach - Same OpenAPI spec for all languages

Why OpenAPI-First?

OpenAPI is an industry standard - it's the universal contract format:

  • Works with all tools and generators
  • Supported by all major cloud providers
  • Standard format for API documentation
  • Perfect for AI/MCP integration
  • Consistent approach across all languages

Notes

  • REST-first philosophy: Simple, stateless, cacheable, MCP-friendly
  • OpenAPI as API contract: OpenAPI specs serve as machine-readable API contracts for tooling, testing, and AI integration
  • Scalar UI for developers: Interactive API documentation automatically generated at /reference endpoint
  • MCP integration: REST interfaces with OpenAPI specs enable AI agents to interact with API via Model Context Protocol
  • Multi-language support: OpenAPI specs can generate clients in Python, Go, Rust, etc. using standard generators
  • TypeScript codegen: hey-api generates fully typed clients from OpenAPI specs
  • Codegen for all languages: All clients generated from same OpenAPI spec for consistency
  • GraphQL available: Can expose GraphQL layer for complex querying if needed, but not primary approach
  • OpenAPI-first design: OpenAPI spec defines the interface, implementation follows
  • Type safety: Full TypeScript types generated from OpenAPI schemas
  • Framework-agnostic: OpenAPI works with any framework, maintains portability goals
  • Testing: OpenAPI specs can be used for contract testing and validation

On this page