Skip to main content

ADR-018: OpenAPI-first API contracts

Context

Frontend TypeScript types are hand-mirrored from backend Kotlin data classes, so API shape drifts silently — e.g. backend AuthUser serializes userHandle/orgHandle while frontend AppUser declares userId/orgId. The platform will have multiple consumers (web console, CLI, CI integrations), so the contract needs a single source of truth that both sides are generated from, with drift caught at compile time.

Decision

Adopt OpenAPI 3.1 spec-first contracts, with code generated from the spec — never hand-written.

  • Specs live in api-spec/rest/v1/<domain>/<name>.yaml. Each is a self-contained OpenAPI 3.1 document: its own schemas inline, and shared types referenced as one-line $ref aliases to api-spec/rest/v1/common/common.yaml. The definition of a shared type (ProblemDetails, AccountType, …) exists once, in common.yaml — no duplication at the YAML or Kotlin level.
  • Kotlin codegen is a pure-JVM Bazel build action (no Node). The OpenAPI Generator CLI runs from its Maven jar as a java_binary inside the kotlin_openapi_library macro, with custom Ktor/kotlinx.serialization Mustache templates, emitting data classes + a route interface + Ktor route wiring + an ApiErrorMapper, compiled by kt_jvm_library. Targets: //backend/shared/api-schema/openapi-types:common and :identity. Cross-domain sharing uses the generator's --schema-mappings (the aliased name maps to the :common package). Output lives in bazel-out.
  • Proto contracts live in api-spec/proto/ (canonical location). java_proto_library generates the message classes, consumed via the //backend/shared/api-schema/proto-types alias. (gRPC service stubs for validation-graph.proto are a later concern.)
  • TypeScript types are generated by tools/scripts/generate-api-contracts.sh (openapi-typescript, dev-time Node) into frontend/packages/api-client (@aucert/api-client).
  • Generated code is never committed. Kotlin/Java/proto are Bazel build outputs; the TypeScript client is gitignored. The spec YAML / .proto files are the review artifact.
  • Conventions are machine-enforced by custom redocly rules in api-spec/rest/redocly.yaml: RFC 9457 problem+json errors (branch on code), X-Request-Id/traceId correlation, 429 + Retry-After, cursor pagination, deny-by-default auth, Idempotency-Key, camelCase.

This departs from the JOOQ precedent (SPEC-019), which commits generated code because its source (a live DB schema) is not human-readable. OpenAPI YAML is the readable source, so generated code stays out of the tree.

Alternatives rejected

  • redocly bundle + aspect_rules_js (Node-in-Bazel). An earlier multi-file spec split (components/ fragments, cross-file $ref) forced a Node bundler to keep schema names clean, which in turn forced a heavy, brittle Node toolchain inside Bazel. Replaced by self-contained specs + named local $ref aliases + --schema-mappings, achieving the same outcome with a pure-JVM build action and no Node in the Bazel graph.
  • Code-first (generate the spec from Kotlin annotations). Makes the implementation the source of truth and blocks parallel frontend/CLI work; rejected.

Consequences

  • API changes are spec-first: edit the YAML, regenerate, implement. The compiler catches drift in the IDE.
  • Frontend/CLI can build against generated types before the backend implements them.
  • Maintenance surfaces: the custom Mustache templates and the named-local-$ref-alias trick both depend on openapi-generator behavior. The generator version is pinned (Maven openapi-generator-cli:7.13.0); re-test the templates and the alias pattern on any generator bump.
  • Engineers must not hand-write request/response types that duplicate a spec (CLAUDE.md Hard Rule 16).
  • This is a setup phase: the generated types are not yet consumed by any domain or the frontend. Enforcement against real consumers (Kotlin servers + Node clients) — and a contract test validating responses against the spec — lands in the next iteration.

References

  • SPEC-039 — full design
  • SPEC-019 — JOOQ codegen (committed-code precedent, departed from)
  • ADR-002 — staged Bazel adoption