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 enforcement — oapi-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