Skip to content
intermediate Phase 10 · API Architecture and Contracts

Contract-First with OpenAPI

Author the API specification first and generate clients, mocks, and tests.

50m
0 problems
Topic Progress 0%

Writing the Spec Before the Handler: The Contract-First Review Loop

The team writes handlers first and documentation second, so the docs describe the API that existed six months ago. Contract-first reverses the order: the OpenAPI document is written, reviewed by its consumers, and only then implemented — and it stays the single source of truth for paths, request and response shapes, status codes, and error formats for the life of the API.

Why consumers should review a YAML file instead of your route table: a client team can critique POST /orders answering 201 with a Location header in a pull request against the spec, without installing your framework or reading controller code. That conversation happens before a line of server code exists, which is the cheapest possible moment to change your mind. It also forces precision the first time — required versus optional fields, which errors are documented, what 409 and 422 each mean for this resource.

Insist on OpenAPI 3.1 rather than 3.0: 3.1 aligns with JSON Schema 2020-12, so oneOf, const, examples, $ref in any position, and type: ["string", "null"] behave exactly as the JSON Schema specification defines. With 3.0 you inherit an approximation — nullable via a vendor extension, subtly different $ref rules — and every generator maps those differences differently.

paths:
  /orders:
    post:
      summary: Create an order
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NewOrder"
      responses:
        "201":
          description: Order created
          headers:
            Location:
              schema:
                type: string
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/Unprocessable"

One document, many consumers: a docs portal renders it, a generator builds the client SDK, a mock server feeds the frontend, contract tests assert against it, and gateways such as Kong or APISIX import it to configure routes. That is what single source of truth earns you — every downstream artifact is derived, so none of them can quietly disagree with the others.

The working loop is four steps: draft the operation in a pull request, lint it (a Spectral ruleset enforces naming conventions, required error responses, and reuse of shared components), get consumer sign-off, then implement. CI keeps it honest afterward — the spec lints, the implementation is tested against the spec, and a diff against the last release flags breaking changes before merge.

Code-first, where annotations in handlers emit a spec at build time, is the common alternative and its failure mode is predictable: the published document becomes whatever the framework serialized, including the accidentally nullable field and the undocumented 500. Nobody reviews a generated file. In a contract-first repository the spec change is the API change: a pull request touching only the YAML is a proposal, and CI answers whether it breaks anyone — the mechanical follow-through of the expand-contract discipline from REST Design and Versioning.

A complete spec also settles decisions that get improvised when code comes first: the security scheme (bearerAuth with per-operation scopes), the pagination convention as a reusable parameter, the shared error model referenced by every non-2xx response, and the server list for staging and production. Each is a cross-service agreement — defined once under components/, reused everywhere, changed in exactly one place. Reuse is also enforceable: a lint rule can reject any response that lacks a documented error schema, so the agreement survives team turnover better than a wiki page does.

Ownership is the unglamorous part that decides whether this lasts. Assign the spec to the team that owns the service, require their review on spec pull requests, and give consumer teams comment rights. A spec nobody is accountable for rots as happily as documentation does; the difference contract-first makes is that rot now surfaces as a failing check instead of a surprise in production.

One Document In, SDK Mock and Stub Out: The Codegen Workflow

The hand-written client wrapper is where contracts go to die: the server adds a field, the wrapper's struct does not, and consumers silently never see it. Generation removes the human from that loop — the SDK is built from the spec, so the SDK cannot disagree with the spec.

Artifact Typical tool Who it serves
Typed client SDK openapi-generator, orval (TS), oapi-codegen (Go) teams calling the API
Server route stub openapi-generator server templates, fastapi-generator the implementing team
Mock server Prism, WireMock importing the spec frontends before the backend exists
Docs portal Redoc, Scalar, Stoplight humans, support, onboarding
Postman/Bruno collection openapi-to-postmanv2 testers and exploratory work

A generated client turns a contract change into a compile error in every consumer repository the moment they upgrade — which is precisely the point. The hand-written wrapper fails silently instead: new parameters go unexposed, renamed fields go unused, and the drift accumulates until someone files a bug about behavior that contradicts the documentation. The spec plus generator replaces that failure mode with a diff anyone can read.

Codegen hygiene is what separates teams that keep generators from teams that abandon them:

  • Pin the generator version in CI. An untagged run changes naming conventions between releases (orderId versus order_id versus OrderID), and your next regeneration becomes a 4,000-line diff that reviewers learn to skip.
  • Commit the generator config next to the spec so laptop output and CI output match byte for byte.
  • Never hand-edit generated files — regeneration overwrites them. Put custom logic in a subclass, a thin wrapper module, or a separate hand-written layer that calls the generated one.
  • Decide nullability, enum, and date handling per language once, at setup. Inconsistent nullable types are the top complaint from consumers of generated SDKs.

Server stubs close the loop in the other direction: the generator emits a route interface with typed request and response structs, so omitting a documented response is a build failure rather than a support ticket. Meanwhile the frontend runs the mock server, which answers with the spec's examples and lets developers force error responses to exercise the unhappy-path screens — no backend deploy required, and no contract divergence, because both sides read the same document.

Wire the whole thing into CI: regenerate on every spec change and open a pull request containing the updated SDK, so reviewers see exactly what the contract change means in consumer code. For REST the enforcement layer is runtime contract testing; gRPC reaches the same guarantee through its own tooling, covered in gRPC and Protobuf Contracts.

Version the artifacts with the API rather than independently. Publish the SDK under its own semantic version mirroring the contract: a breaking spec change bumps the SDK major, an additive one bumps the minor, and consumers upgrade on their own schedule while contract tests keep the server honest toward older callers. Feed the mock the same spec, and frontend CI can smoke-test error handling against documented 409 and 422 responses long before the backend implements them — the mock doubles as an executable example of the contract for anyone reading it.

The developer-experience difference compounds over months. A typed SDK turns a required field into a compile error at the call site, an enum into autocomplete, and an optional parameter into something visibly optional; the hand-written wrapper offers none of that, because its types were transcribed from memory of the server instead of derived from the contract. Reviews of consumer code get faster too — reviewers see intent in the types rather than tracing string literals into documentation. That is the payoff of keeping one document at the center of every artifact.

Failing the Build When the Implementation Diverges from the Spec

Documentation rots because nothing checks it. Contract testing closes that gap by treating the published spec as an executable assertion about the running server.

Three gates, three distinct failure signals:

Gate Example tool Fails when
Spec lint Spectral naming rules broken, an error response missing, components duplicated
Breaking-change diff oasdiff, Optic a field removed, a required parameter added, a response narrowed versus the last release
Runtime contract test schemathesis, Dredd a live response violates the schema or uses an undocumented status code

The runtime gate is where surprises surface. Schemathesis derives inputs from your spec itself and fuzzes the running service: it posts payloads that satisfy the schema, then asserts every response matches the documented schema and status codes. Typical first-run finds: a 500 on a valid request, total returned as the string "12" where the spec promises an integer, a required field vanishing when a related record is absent, and validation errors answered as 400 when the spec committed to 422. Each one is a place where reality and the contract already disagree — bugs that would otherwise surface as a confused consumer at 2 a.m.

# contract-first CI pipeline
- name: lint spec           # spectral lint openapi.yaml
- name: breaking changes    # oasdiff diff released.yaml candidate.yaml --fail-on-diff
- name: boot service        # docker compose up -d, wait for /healthz
- name: contract tests      # schemathesis run https://localhost:8443/openapi.yaml

Contract tests are deliberately narrow: they verify the boundary — shapes, statuses, headers, required fields — and nothing about your domain logic. Unit and integration tests own the internals; the contract suite owns the promise you made to consumers. Because the assertions are derived from the spec, they cannot go stale the way hand-written expectation files do: update the spec and the expectations follow.

Know the limits too. Contract tests will not catch a slow query, a wrong price calculation, or a race in fulfillment — and a schema match can still hide a semantic lie, such as a status that satisfies the enum but describes the wrong state. Keep behavioral tests alongside; use the contract suite as the gate on the published surface.

Consumer-driven variants add one more voice: a consumer publishes the subset of the contract it genuinely relies on, and the provider's build fails when that subset stops being satisfied. Used sparingly, for genuinely critical integrations, it converts the mobile app broke again into a red pipeline before merge.

The habit interviewers listen for is documentation that can fail the build. Ask any candidate how their team keeps API docs accurate and you learn whether the answer rests on discipline or on enforcement.

Placement in the pipeline matters as much as the tools. Run lint and the breaking-change diff on every pull request — they cost milliseconds. Run the runtime contract suite against an ephemeral deployment of the service on merge, and against staging nightly with production traffic replayed through a proxy, so undocumented real-world paths surface too. Together the two directions of drift are covered: code that no longer matches the spec, and clients calling shapes the spec never described.

Be ready to contrast contract tests with integration tests in a single sentence: integration tests assert that your system behaves correctly end to end, while contract tests assert that the published promise still holds — different failure messages, different owners, both necessary. A stronger candidate also names what happens when the spec itself is wrong: the failing test forces a conversation about which side is correct, and the outcome is recorded in the spec rather than in a chat thread.

Quiz

1. What distinguishes contract-first from code-first OpenAPI generation?

Question 1 options

2. A teammate regenerates the SDK in CI and the diff shows thousands of purely cosmetic changes (orderId vs order_id vs OrderID). What is the root cause?

Question 2 options

3. Which CI gate catches the case where the server returns HTTP 200 with a body missing a field the spec marks required?

Question 3 options

4. You delete a response field in the spec and want to know before release whether existing consumers break. Which mechanism answers that?

Question 4 options

Flashcards

Question

What does contract-first mean, and what is the single source of truth in that workflow?

Answer

The OpenAPI document is authored and reviewed by consumers before any handler is written, and it remains the authoritative description of paths, shapes, status codes, and errors. SDKs, mocks, docs, gateway routes, and contract tests are all derived from that one document, so they cannot silently disagree with it.

Question

Why pin the OpenAPI generator version, and what goes wrong otherwise?

Answer

Generator releases change default naming, casing, nullability, and enum mappings. Unpinned runs produce different code for the same spec on every machine, turning the next regeneration into a massive cosmetic diff that reviewers ignore — after which real contract changes slip through unreviewed.

Question

What are the three CI gates in a contract-first pipeline and what does each catch?

Answer

1) Spec lint (Spectral): style, naming, required error responses, component reuse. 2) Breaking-change diff (oasdiff/Optic): removals, new required inputs, narrowed responses versus the released spec. 3) Runtime contract test (schemathesis/Dredd): live responses violating schema, wrong or undocumented status codes, undocumented 500s on valid input.

Question

What does a generated client SDK give you that a hand-written wrapper does not?

Answer

Types compiled from the contract itself, so spec changes become compile errors in consumer code at upgrade time. A hand-written wrapper drifts silently: new parameters go unexposed, renamed fields go unused, and the divergence only surfaces as a bug report that contradicts the docs.

Question

What can contract tests not catch, despite passing?

Answer

Anything about behavior or performance: slow queries, incorrect calculations, race conditions, or authorization bugs. A response can satisfy the schema and still be semantically wrong (a status from the documented enum describing the wrong state), so behavioral unit and integration tests must run alongside the boundary checks.

Revision Notes

Key Takeaways

  • 1. Contract-first means the reviewed YAML is the input to implementation; a pull request that touches only the spec is a proposal CI can judge for breaking changes
  • 2. One spec drives SDK, mock, docs, gateway routes, and tests — derived artifacts cannot silently disagree with their source
  • 3. Pin the generator and commit its config or regeneration produces cosmetic megadiffs that teach reviewers to skip real changes
  • 4. Runtime contract tests (schemathesis, Dredd) are the only gate that compares live responses to the documented schema, catching wrong types, missing required fields, and undocumented errors
  • 5. Contract tests own the boundary, not the behavior: schema-valid responses can still be semantically wrong, so behavioral suites stay

Interview Tips

  • • "How do you keep API documentation accurate?" — answer with enforcement, not discipline: the spec is generated from and tested against the implementation, and CI fails on drift; name Spectral, oasdiff, and schemathesis as the three gates
  • • "What changed between OpenAPI 3.0 and 3.1?" — JSON Schema 2020-12 alignment: real `type: ["string","null"]`, `const`, `$ref` anywhere; mention that generators mapped 3.0 nullable inconsistently
  • • "How do you prevent breaking changes from shipping?" — diff the candidate spec against the last released one in CI, fail on breaking classification, then let expand-contract stage the actual migration
  • • "What does codegen break on a team?" — unpinned versions, hand-edited output, and unclear nullability rules; show you know the operational cost as well as the benefit

Cheat Sheet

Contract-First with OpenAPI

The loop: draft operation in a PR → lint (Spectral) → consumer sign-off → implement → verify against spec → diff vs last release.

OpenAPI version: 3.1 = JSON Schema 2020-12 (oneOf, const, type: ["string","null"], $ref anywhere). 3.0 = approximation (vendor-extension nullable) that generators map inconsistently.

Pipeline:

- lint spec           # spectral lint openapi.yaml
- breaking changes    # oasdiff diff released.yaml candidate.yaml --fail-on-diff
- boot service        # docker compose up -d, wait for /healthz
- contract tests      # schemathesis run <spec-url>

Generated artifacts:

Artifact Tool Consumer
Typed SDK openapi-generator / orval / oapi-codegen calling teams
Server stub generator server templates implementing team
Mock server Prism / WireMock frontend before backend ships
Docs portal Redoc / Scalar / Stoplight humans and support
Postman collection openapi-to-postmanv2 testers

Codegen hygiene: pin the generator version; commit generator config; never hand-edit generated files (wrap instead); fix nullability/enum/date mappings per language once.

Gate ownership: lint = style, diff = compatibility, contract test = live conformance, unit/integration = behavior. None of the first three checks business logic; none of the last one checks the published surface.

Continue Learning