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:
# 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.
# 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.BaseModelHandlers 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.jsonis reviewable by people who will never read the handler. - Breaking-change detection. Diffing two spec versions is mechanical;
oasdiffand 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
4xxshapes rot, and consumers parse errors that no longer look like that.
Related
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
4xxschema.