Skip to main content

ADR-004: OpenAPI-First Go API with Per-Module Specs and oapi-codegen

Date: 2026-02-19

Status: Accepted

Context

The Chompardo Go API is a modular monolith where each module (recipe, ingredient, etc.) is a bounded context with its own domain, application layer, and transport layer. The API is consumed exclusively by the app's BFF (see ADR-003) — it is not browser-facing.

We need to decide:

  1. Transport protocol — HTTP/JSON, GraphQL, gRPC, or Connect (Buf) for BFF-to-API communication
  2. API design approach — Code-first (write handlers, derive spec) vs spec-first (write OpenAPI, generate code)
  3. Spec ownership — One monolithic API spec vs per-module specs
  4. Code generation tool — Which Go library generates server stubs and types from OpenAPI

Key forces:

  • The only consumer is the Node.js BFF, which proxies requests with fetch — the transport should be simple to call from TypeScript without special client libraries
  • The team is small and pragmatic; we want to minimize tooling and concepts that don't directly serve product development
  • Each module is an independent bounded context that should own its full stack, including its API surface
  • The API spec doubles as living documentation — it is served at /openapi.yaml and rendered in our Docusaurus docs site
  • We value compile-time safety — generated types and server interfaces should catch contract drift before runtime

Decision

HTTP/JSON as the transport protocol

We use a standard HTTP/JSON API. The BFF calls the Go API with plain fetch over HTTP. No special client libraries, no binary protocols, no schema compilation step on the BFF side.

OpenAPI-first (spec-first) design

The OpenAPI 3.0 spec is the source of truth for the API contract. We write the spec first, then generate Go server interfaces and types from it. The workflow is: edit YAML → run go generate → implement the interface.

Per-module spec ownership

Each module owns its own openapi.yaml at internal/modules/<name>/transport/api/openapi.yaml. Shared types (like the Error schema) live in internal/shared/api/schemas.yaml and are referenced via relative $ref paths. A bundled spec at internal/shared/api/openapi.bundled.yaml aggregates all module specs for serving at runtime and rendering in docs.

oapi-codegen for Go code generation

We use oapi-codegen (github.com/oapi-codegen/oapi-codegen) with per-module config files (oapi-codegen.yaml). Each module generates a server.gen.go containing a ServerInterface and chi router wiring. Shared schemas generate types.gen.go in the shared package. Cross-spec references are resolved via import-mapping in the codegen config.

Consequences

Positive

  • Trivial BFF integration — The BFF calls the Go API with standard fetch; no Protobuf client, no GraphQL library, no code generation on the TypeScript side
  • Spec as living documentation — The OpenAPI spec is served at /openapi.yaml and rendered in Docusaurus, giving the team always-accurate API docs with no extra effort
  • Compile-time contract enforcementoapi-codegen generates a ServerInterface that each module must implement; if the spec adds an endpoint, the code won't compile until the handler exists
  • Module autonomy — Each module owns its spec, codegen config, and generated code. Adding a new module's API surface requires no changes to other modules
  • Cross-module type sharing — The import-mapping feature lets module specs reference shared schemas (e.g., Error) without duplicating types; the generated code uses proper Go imports
  • Incremental adoption — New modules get their own spec and codegen config without touching existing ones; the bundled spec aggregation is additive
  • Debuggability — HTTP/JSON is human-readable in server logs and curl output; easy to inspect BFF-to-API traffic during development

Negative

  • No built-in streaming — HTTP/JSON doesn't natively support server push; if we need real-time features we'll add WebSockets or SSE separately
  • Spec-first requires discipline — Developers must edit YAML before writing Go code, which inverts the usual workflow; forgetting go generate produces stale code (mitigated by CI checks)
  • OpenAPI YAML verbosity — Spec files can grow large for complex endpoints; maintaining deeply nested request/response schemas in YAML is more tedious than writing Go structs directly
  • Spec bundling is an extra step — Aggregating per-module specs into the bundled output requires tooling and must be run after spec changes

Alternatives Considered

1. GraphQL

Schema-driven query language with a single endpoint. Clients declare exactly which fields they need. Typically implemented in Go with gqlgen.

Rejected because:

  • The BFF is the sole consumer and knows exactly which fields it needs — GraphQL's flexible field selection solves a problem we don't have since we control both sides
  • The resolver pattern introduces a layer of indirection that doesn't map well to our clean architecture with explicit application services
  • Our modules have relatively flat, well-scoped endpoints — we don't have deeply nested graph-like data relationships where GraphQL shines
  • Schema stitching or federation would be needed to preserve module boundaries, adding significant infrastructure for what is currently a monolith

2. gRPC (with Protobuf)

High-performance RPC framework using Protocol Buffers for schema definition and binary serialization. The BFF would use a gRPC client to call the Go API.

Rejected because:

  • Requires protoc compilation on the TypeScript (BFF) side to generate client stubs, adding build-time tooling and a non-trivial dependency
  • Binary payloads are not human-readable in server logs without extra tooling — painful for debugging during early development
  • The performance benefits of binary serialization and HTTP/2 multiplexing are irrelevant for BFF-to-API calls over localhost or a private network at our current scale
  • gRPC's streaming capabilities are powerful but we don't have streaming use cases yet; adding that complexity upfront is premature

3. Connect (Buf)

Modern RPC framework by Buf that speaks gRPC, gRPC-web, and Connect protocols over standard HTTP. Uses Protobuf schemas with the buf CLI for generation.

Rejected because:

  • Still requires Protocol Buffers as the schema language and buf/protoc tooling on both the Go and TypeScript sides
  • Introduces a new ecosystem (Buf CLI, Buf Schema Registry, connect-go, connect-es) that the team would need to learn and maintain
  • The Protobuf-first workflow means the API schema lives in .proto files — we lose the ability to serve a self-describing OpenAPI spec and render interactive docs without conversion tools
  • The Go server library (connect-go) has a smaller ecosystem and fewer middleware options compared to standard net/http + chi
  • Connect solves problems (gRPC interop, streaming, binary efficiency) that we don't currently have

4. go-swagger

OpenAPI 2.0 (Swagger) code generator that produces both server and client code with significant runtime scaffolding.

Rejected because:

  • Only supports OpenAPI 2.0 (Swagger); does not support OpenAPI 3.x features we use (e.g., oneOf, richer schema composition, component reuse via $ref)
  • Generates a heavy runtime framework with its own middleware stack, routing, and validation — we want thin generated code that wires into our existing chi router
  • The generated code is opinionated about project structure, conflicting with our modular monolith layout where each module owns its transport layer

5. openapi-generator (Java-based)

Multi-language OpenAPI code generator that supports many target languages and frameworks. Runs as a Java CLI or Docker container.

Rejected because:

  • Requires a JVM or Docker to run generation, adding a non-Go dependency to the build toolchain
  • The Go server templates produce code that doesn't integrate cleanly with chi or standard net/http patterns
  • Template quality for Go is inconsistent; the generator is optimized for Java/TypeScript ecosystems
  • Being multi-language means Go-specific features (like import-mapping for cross-package refs) are less polished than in a Go-native tool

6. ogen

High-performance Go OpenAPI code generator focused on correctness and speed. Generates both client and server code with a zero-reflection approach.

Rejected because:

  • Generates its own HTTP server implementation rather than producing handlers that mount on chi — integrating with our existing middleware stack (CORS, auth, request ID) would require adapter code
  • The generated server API uses ogen's own types and interfaces, not standard net/http handlers, reducing portability
  • Smaller community and ecosystem compared to oapi-codegen; fewer examples and less battle-testing in production
  • oapi-codegen produces a ServerInterface with plain http.ResponseWriter/*http.Request signatures that are idiomatic Go and trivially testable