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$refaliases toapi-spec/rest/v1/common/common.yaml. The definition of a shared type (ProblemDetails,AccountType, …) exists once, incommon.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_binaryinside thekotlin_openapi_librarymacro, with custom Ktor/kotlinx.serialization Mustache templates, emitting data classes + a route interface + Ktor route wiring + anApiErrorMapper, compiled bykt_jvm_library. Targets://backend/shared/api-schema/openapi-types:commonand:identity. Cross-domain sharing uses the generator's--schema-mappings(the aliased name maps to the:commonpackage). Output lives inbazel-out. - Proto contracts live in
api-spec/proto/(canonical location).java_proto_librarygenerates the message classes, consumed via the//backend/shared/api-schema/proto-typesalias. (gRPC service stubs forvalidation-graph.protoare a later concern.) - TypeScript types are generated by
tools/scripts/generate-api-contracts.sh(openapi-typescript, dev-time Node) intofrontend/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 /
.protofiles are the review artifact. - Conventions are machine-enforced by custom redocly rules in
api-spec/rest/redocly.yaml: RFC 9457problem+jsonerrors (branch oncode),X-Request-Id/traceIdcorrelation,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$refaliases +--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 onopenapi-generatorbehavior. The generator version is pinned (Mavenopenapi-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.