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
| Feature | OpenAPI + hey-api ✅ | tRPC | GraphQL-First | Plain 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/andpackages/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.jsonspec for reference - Good for: simple integrations, prototyping, low request volume
Option 2: Generate SDK (When It's Worth It)
- Generate from
/openapi.jsonusing 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-api2. 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 gen4. 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.goMigration 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
/referenceendpoint - 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
ADR 008: Database Platform & Strategy
Decision to use PostgreSQL with Supabase as initial managed provider and Drizzle ORM, designed for portability and easy migration to Google Cloud SQL or AWS RDS for production security.
How-To Guides
Step-by-step guides for common development tasks in this monorepo.