Contracts With Neighbours
Stagedoor exposes an API to the scanner app and the web client, and consumes one from Payrail, and both relationships break the same way: one side changes a shape and the other side finds out from a buyer. A contract is the written form of the shape, kept where a machine can check it. For what Stagedoor exposes, the contract is the OpenAPI document, generated from the request and response models of Chapter 3 so it cannot drift from the parser, committed to the repository, and diffed in CI. For what Stagedoor consumes, the contract is the pinned version the client was written against, the strict response models, and a scheduled test that runs the real client against Payrail's sandbox so that a change at Payrail is caught by a test on Tuesday morning rather than by a checkout on Saturday night.
Two directions, and one idea under both: the shape a neighbour depends on is a promise, and a promise nobody can check is a rumour. The scanner's team should read a diff, not Stagedoor's code, to learn what changed. Marek should learn that Payrail renamed a field from a red test, not from 30 orders marked unknown. This topic is the machinery for both, and the honest section at the end on what a schema cannot say.
OpenAPI, Generated
FastAPI produces the OpenAPI document from the same objects that parse the requests: the Pydantic model on each route becomes the request schema, the response_model and the declared responses become the response schemas, and the route's method, path, status code and parameters become the operation. The document is served at /openapi.json and rendered at /docs, and it is a 3.1 document because that is what current FastAPI emits. Nobody writes it. The HoldRequest model of Topic 14 of Chapter 3, with extra="forbid" and a pattern on the seat label, becomes a schema with additionalProperties: false and the same pattern, so the document says exactly what the boundary enforces, because it was built from the boundary.
{
"paths": {
"/holds": {
"post": {
"operationId": "create_hold",
"requestBody": {"required": true, "content": {"application/json": {
"schema": {"$ref": "#/components/schemas/HoldRequest"}}}},
"responses": {
"201": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HoldResponse"}}}},
"409": {"content": {"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}}}
}}}},
"components": {"schemas": {
"HoldRequest": {
"type": "object", "additionalProperties": false, # extra="forbid", as the parser has it
"required": ["event_id", "seat_label"],
"properties": {
"event_id": {"type": "integer", "exclusiveMinimum": 0},
"seat_label": {"type": "string", "pattern": "^[0-9]{1,3}[A-Z]$"} # the SeatLabel constraint
}}}}}
The fragment is the hold endpoint as the document describes it: one operation, a required body of type HoldRequest, a 201 with a HoldResponse, and a 409 whose body is a Problem Details object under the application/problem+json media type that RFC 9457 defines and Topic 15 of Chapter 3 adopted. Below it the HoldRequest schema carries the two required fields, the rule that no other field is allowed, the positive-integer constraint on the event id and the pattern on the seat label, all of which came from the model's annotations and none of which anyone typed into a YAML file. A hand-written document is wrong by the second release, because the person who changes the model is not the person who remembers the document; a generated one cannot be wrong about the shapes, because it and the parser are the same source read twice.
The document is regenerated on every build and committed to the repository beside the code. That sounds redundant, since it can be produced from the code at any time, and the redundancy is the point: a committed file has a history, a diff appears in the pull request that changed a model, and a reviewer sees "the response to GET /orders/{public_id} lost a field" as a line in the review rather than as a consequence discovered by the mobile app three weeks later.
The Document as a Gate
A CI step regenerates the document, diffs it against the committed one, and classifies every difference using the list from Topic 17 of Chapter 3: a removed field, a renamed field, a changed type, a new required parameter, a tightened constraint or a changed status code is breaking; a new optional field, a new endpoint, a new enum value or a new parameter with a default is additive. The build fails on a breaking change unless the change is marked as a dated version in the header scheme Topic 17 set up, in which case the diff must also show the translation for older clients. The tool is oasdiff, which reads two documents and prints the breaking changes with the path and the field, and it runs in under a second.
The gate is what turns the document from a description into a contract. Before it, the renamed field shipped, the scanner app broke at the door on the night, and the fix was a hotfix to the service at 19:40 with 2,000 people in the queue. After it, the rename fails the build of the engineer who made it, in the pull request, with a message naming the field, and the engineer either adds the new field beside the old one, which is the expand-then-contract of Topic 17, or marks a version and writes the translation. The scanner app's team reads the diff of the committed document, not Stagedoor's source, to learn what changed and when, and the document's history is the API's changelog for free.
Consuming Payrail
In the other direction Stagedoor is the client, and the contract is Payrail's OpenAPI document at a pinned version. Payrail versions by date, as Stagedoor does, and the client sends Payrail-Version: 2026-03-01 on every request, the date the client was written and tested against. Payrail's side then presents the API as it was on that day for as long as their sunset allows, and a change they make in June does not reach Stagedoor until Marek moves the date, reads their changelog for the range, and runs the contract test of the next section. An unpinned client sees Payrail's latest shape on every deploy of theirs, which is a change to Stagedoor's production made by somebody else's release calendar.
The response models of Topic 53 are the other half. They are strict about types and about the values of status, and they ignore fields they do not know. The day Payrail adds network_ref to the charge response, the model ignores it and nothing happens, which is the contract's rule that clients tolerate unknown fields, applied by Stagedoor as a client. The day Payrail renames amount_cents to amount_minor behind a new version date, the model fails to parse the moment the pinned date is moved past it, and the failure happens in the contract test, on a schedule, against the sandbox. It does not happen in production, because production's date has not moved.
The Contract Test
The fake Payrail of Topic 65 of Chapter 12 is what the test suite runs against, 400 times a build, in memory, deterministic. It is exactly as honest as the last time somebody compared it with Payrail, which is why a test exists whose only job is that comparison. The contract test runs the real PayrailClient against Payrail's sandbox, on a schedule rather than on every build because the sandbox is slow, shared and rate-limited, and it asserts the shapes the fake assumes: that a charge with a valid test card parses into a ChargeResponse with a status of charged, that the test card ending in 0002 produces a 402 that parses into a status of declined with a decline code, that a second charge with the same key returns the first charge's id, and that a refund of that charge parses.
# tests/contract/test_payrail.py: runs at 04:00 from CI, never in the unit suite async def test_charge_shapes(sandbox_client: PayrailClient, order): charged = await sandbox_client.charge(order, source="pm_test_visa", ctx=ctx) assert charged.outcome == "charged" and charged.provider_ref.startswith("ch_") again = await sandbox_client.charge(order, source="pm_test_visa", ctx=ctx) # same key: order-{public_id} assert again.provider_ref == charged.provider_ref # their idempotency, as the fake models it declined = await sandbox_client.charge(other_order, source="pm_test_0002", ctx=ctx) assert declined.outcome == "declined" and declined.decline_code == "insufficient_funds" refunded = await sandbox_client.refund(charged.provider_ref, key=f"refund-{order.public_id}") assert refunded.outcome == "refunded" # a parse failure anywhere above is Payrail's shape moving: fix the model, then the fake, then the date
The test charges the sandbox with a test card and checks that the answer parses into the charged shape with a provider reference of the expected form; charges again with the same order, and so the same key, and checks that Payrail returns the same reference, which is the idempotency the fake imitates; charges with the test card that always declines and checks the declined shape and its code; and refunds the first charge and checks that shape too. Four calls, four shapes, and each one is something the fake produces from memory. When any assertion or parse fails, the sequence is fixed: update the model to Payrail's new shape, update the fake to match, and only then move the pinned date in production. A fake that nobody checks against the provider drifts silently, and the failure mode is precise: a suite that has been green for six months and a production that has been wrong for five of them.
Consumer-Driven Contracts
The OpenAPI gate protects every field Stagedoor exposes equally, and that is more than the scanner needs and less than it deserves. The scanner reads GET /tickets/{code} and uses three fields of the response: the seat label, the holder's name, and the event's title. It does not care about the other nine. A consumer-driven contract inverts the direction: the scanner's team writes down the three fields it needs, in a file Stagedoor's CI can read, and Stagedoor's build verifies that the endpoint still provides them. A change that drops or renames one of the three fails Stagedoor's build, with the scanner named as the consumer that needs it. A change to any of the other nine is Stagedoor's business.
The tool for this is Pact: the consumer's test produces the contract file, a broker stores it, and the provider's verification step replays it against the running service. The book shows the idea in one example rather than the tool, because the idea is the part that survives a change of tools. What it inverts is who owns the definition of "breaking." Under the OpenAPI gate, Stagedoor decides which changes break clients, from the list. Under a consumer contract, each client says what would break it, and a field nobody has declared a need for can be removed without ceremony, which is how the nine unused fields on the ticket response finally went away.
What a Contract Does Not Cover
A schema describes shapes, and most of what a neighbour relies on is not a shape. That a hold expires in 10 minutes is behaviour; the schema shows an expires_at field and says nothing about when it will be. That the seat map answers in under 800 milliseconds at 2,600 requests a second is performance, and no line of OpenAPI expresses it. That an order's status moves only along the edges Topic 56 drew, and that refunded is terminal, is semantics: the schema lists the six string values and cannot say which follow which. A client that reads the schema alone learns the vocabulary and none of the grammar.
Those things are written in prose beside the schema, in the description fields the models carry and in the API document the scanner's team reads, and they are tested end to end in the tests of Topic 63 of Chapter 12: a test that holds a seat, advances the frozen clock 10 minutes, and asserts the seat is available; a test that walks an order through the machine and asserts the illegal transitions are refused. The contract and the tests cover different failures. The contract catches the field that was renamed. The test catches the hold that stopped expiring. A team that has only the first believes the API is stable because the shapes have not moved, and a transition nobody documented lives in a chat thread until the day the person who wrote it leaves.
- A hand-written OpenAPI document — wrong by the second release, and the client the scanner's team generated from it wrong in the same places, with nothing to say which side is lying.
- No diff gate — the renamed field ships in a Tuesday deploy, the scanner app breaks at the door on Saturday, and the fix is a hotfix at 19:40 with 2,000 people in the queue.
- A fake Payrail that nobody checks against Payrail — the shape changed six months ago, the suite has been green every day since, and production has been mapping the new shape to "unknown outcome" for five of those months.
- Consuming the provider's latest version — no pinned date, so Payrail's release calendar changes Stagedoor's production, and the client that worked on Friday fails on their Monday deploy.
- A contract that covers shape only, with the semantics in a chat thread — the schema lists six status values, nobody wrote down that
refundedis terminal, and a client retries a settlement into it.
- Generate the OpenAPI document from the request and response models, commit it on every build, and fail CI with
oasdiffon any breaking change that is not a marked version. - Pin the provider's version by date in a header, parse responses strictly with unknown fields ignored, and move the date only after the contract test passes against the new shape.
- Run the real
PayrailClientagainst the sandbox on a schedule, assert the shapes the fake produces, and fix the model, the fake and the date in that order when it fails. - Let each consumer declare the fields it needs and verify them in the provider's CI, so a removal breaks the build of the side that made it, not the client.
- Document behaviour, performance and state transitions in prose beside the schema and test them end to end, because a schema lists values and cannot order them.
Knowledge Check
Why is Stagedoor's OpenAPI document generated from the request models rather than written by hand?
- It cannot disagree with the parser, because both come from the same models
- Writing the YAML for 30 endpoints by hand would take longer than the code did
- FastAPI refuses to serve any document at /openapi.json that it did not produce
- A generated document is the only kind of document that oasdiff can compare
A pull request renames starts_at to begins_at in the event response. What does the CI gate do, and what are the engineer's two ways through it?
- Passes it, because a rename keeps the same type and only changes the label of a field
- Passes it if no consumer contract on the broker lists the renamed field as one it reads
- Fails it, unless the field is added beside the old one or a dated version is marked
- Deploys it to staging and fails only if a client there still sends the old name
What keeps the in-memory fake Payrail honest?
- The strict response models, because the fake is built from the same Pydantic classes
- A scheduled test of the real client against the sandbox, asserting the same shapes
- The unit test suite, which exercises the fake about 400 times on every single build
- The pinned version date, which freezes the shape the fake was written against
What does a consumer-driven contract invert, compared with the OpenAPI diff gate?
- Which side sends the request, so the scanner serves the ticket endpoint
- Who generates the OpenAPI document, so each client produces its own copy
- Who defines breaking, so a change breaks only what some consumer declared it needs
- Where the tests run, so the scanner's suite executes Stagedoor's handlers
The order schema lists six status values. Which of these can the OpenAPI document express about them?
- That refunded is a terminal state and that paid always follows charging
- That an order left in charging is resolved by reconciliation within 15 minutes
- That the order status endpoint answers in under 800 milliseconds at the P99
- That status is a string that takes exactly one of the six values
You got correct