Backend / REST APIs / 11_contract_first_api_design.md

Contract-first API design

Updated 4 interview angles 4 min read source
On this page7
  1. Code-first vs contract-first
  2. Doing contract-first with FastAPI
  3. What the contract is for
  4. Consumer-driven contracts, the other direction
  5. Where it breaks in production
  6. Related
  7. Interview angle

Contract-first API design

Two ways to arrive at an API spec: write the code and generate the spec from it, or write the spec and generate stubs from it. FastAPI is emphatically the first — the OpenAPI document falls out of the route signatures — and that is exactly why the contract-first question comes up in FastAPI interviews.

Code-first vs contract-first

Code-first Contract-first
Source of truth the handlers the spec file
Spec is generated authored, reviewed
Consumers start after the code in parallel
Drift impossible possible, so it is checked
Framework fit FastAPI, DRF any, with generation

Code-first is faster for one team owning both ends. The spec is always accurate because it is derived, and Pydantic models double as the schema.

Contract-first is for seams: when a frontend team, a partner, or another service needs to build against the API before it exists, or when the API is the product and its shape deserves review on its own. The spec — OpenAPI, protobuf, or a GraphQL SDL — is a file in the repo, reviewed in a pull request, versioned, and the code is checked against it.

The decision, stated plainly: contract-first at the seams between teams; code-first inside one.

Doing contract-first with FastAPI

The tension is that FastAPI wants to generate the spec. Teams that want the spec authoritative keep it as a file and add one CI step:

python
# tests/test_contract.py - the generated spec must match the committed one.
import json
from pathlib import Path
from app.main import app

def test_openapi_matches_contract():
    generated = app.openapi()
    committed = json.loads(Path("openapi.json").read_text())
    assert generated["paths"].keys() == committed["paths"].keys()
    assert generated["components"] == committed["components"]

The workflow becomes: change the contract file, regenerate models from it, implement, and the test fails until the code matches the contract. That gives you the review-the-spec-first property without fighting the framework.

bash
# The contract owns the schemas; Pydantic classes are generated, not written.
datamodel-codegen --input openapi.json --input-file-type openapi \
    --output app/generated/models.py --output-model-type pydantic_v2.BaseModel

Handlers import from app/generated/ and never edit it - which is the discipline that makes the whole thing hold, and the one that gets broken first.

For protobuf / gRPC the contract-first shape is native — .proto files are the only source, protoc generates both sides, and there is no code-first option. See RPC and gRPC.

For GraphQL, schema-first (SDL) versus code-first (Strawberry’s decorated classes) is the same debate with the same answer.

What the contract is for

  • Parallel work. The frontend generates a typed client from the spec and builds against a mock server on day one. See OpenAPI and the generated docs.
  • Review of the shape. A pull request that changes only openapi.json is reviewable by people who will never read the handler.
  • Breaking-change detection. Diffing two spec versions is mechanical; oasdiff and similar tools flag a removed field or a narrowed type before a consumer finds it.
  • Mocking and contract tests. The spec drives a mock server for consumers, and validation middleware can reject requests and responses that violate it in staging.

Consumer-driven contracts, the other direction

Contract-first says the provider publishes a contract. Consumer-driven contracts flip it: each consumer records what it actually uses, and the provider’s CI runs those expectations. Pact is the tool, and it catches the case a spec cannot — a field the provider considers optional that one consumer depends on. Mechanics in Integration and Contract Testing.

The two compose: the OpenAPI contract says what the API offers; Pact says what each consumer needs. Breaking either is a build failure.

Where it breaks in production

  • Two sources of truth. A committed spec nobody regenerates, and a generated one nobody reads. The CI equality test is what prevents this.
  • Contract-first for one team. Ceremony with no consumer to protect. The spec review adds a step and buys nothing until a second team exists.
  • Hand-edited generated models. Someone tweaks a Pydantic class the generator owns, and the next regeneration discards it.
  • Additive changes treated as breaking. Adding an optional response field is safe; a diff tool that flags every change trains people to ignore it.
  • Spec drift on error responses. Happy-path schemas are maintained; the 4xx shapes rot, and consumers parse errors that no longer look like that.

Interview angle 4

  • “Code-first or contract-first?” - contract-first at seams between teams, where a consumer builds in parallel and the shape deserves its own review; code-first inside one team, where a generated spec is always accurate and the ceremony buys nothing.
  • “FastAPI is code-first. How do you do contract-first with it?” - keep the spec as a committed file, generate Pydantic models from it, and add a CI test asserting the generated OpenAPI matches the committed one. The framework still generates; the test makes the file authoritative.
  • “What does Pact catch that an OpenAPI spec does not?” - a consumer depending on something the spec calls optional. The spec describes what is offered; consumer-driven contracts describe what is needed, and only the second one breaks when a provider drops a field nobody thought mattered.
  • “How do you detect a breaking change?” - diff the spec in CI, with a tool that knows additive from breaking - a removed or narrowed field fails, a new optional one does not. Follow-up: what usually rots first? Error response shapes, because nobody tests the 4xx schema.