Contract Tests Between Services
Verify that a producer and its consumers still agree on the wire format, without running both systems at once.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
How do I know a change to my API will not break a service I do not control and cannot run?
Three services consume our orders API. We want to add a field, rename another and drop a third, and know before deploying which consumers that breaks.
Run end-to-end tests in a shared staging environment with every service deployed. If checkout still works, the change is safe.
Staging drifts. Consumers there are not the versions in production, so a green staging run proves something about a system nobody is running.
- Staging drifts. Consumers there are not the versions in production, so a green staging run proves something about a system nobody is running.
- It scales badly and then not at all: every consumer must be deployed, configured and seeded, and one broken service turns everyone's pipeline red for reasons unrelated to their change.
- It is slow and flaky, so failures get re-run rather than read — and the one real failure is dismissed with the noise.
- Coverage is accidental. The staging flow exercises the fields that flow happens to use; a consumer relying on a field no staging test touches breaks silently.
- It gives no early signal. The producer learns about the break during an integration run, long after the design decision that caused it (Backward Compatibility: The Real Rules).
What is actually happening
- A contract test verifies agreement about a format, not correctness of behaviour, and it does so with each side tested alone against a shared artifact.
- In the consumer-driven form, each consumer declares what it actually needs — these fields, these types, these status codes — and that expectation becomes an artifact. The producer replays every consumer's expectations against its real implementation in its own pipeline (Consumer-Driven Evolution: Telemetry Before Breakage).
- That inversion is the whole value: the producer learns, in its own CI, that removing a field breaks consumer B, without deploying or even possessing consumer B.
- In the schema-driven form, a shared schema — OpenAPI, protobuf, JSON Schema, Avro — is the artifact, and both sides validate against it. It catches shape violations but not "who actually depends on this field", so it cannot tell you a field is safe to remove (OpenAPI: Describing the Contract, Not Designing It).
- The two are complementary. A schema defines what is possible; consumer expectations define what is *used*, which is the question that matters for removals (Removing Fields Without Removing Consumers).
- Contracts extend beyond HTTP: an event schema published to a queue has consumers with exactly the same problem, and events are usually worse because consumers are invisible to the producer (Writing Event Consumers).
- A contract test says nothing about whether the producer's behaviour is right. Both sides can agree perfectly on a format and the producer can still return the wrong prices.
What each mechanism can and cannot tell you
The distinctions matter because teams adopt one mechanism and assume it answers all the questions. It does not. "Is this shape valid" and "does anyone actually use this field" are different questions, and only the second one authorises a removal.
The last column is the one to read carefully — it is what you are still exposed to after adopting that mechanism.
| Mechanism | Artifact | Answers | Still cannot tell you |
|---|---|---|---|
| Consumer-driven contract | Expectations generated from each consumer's tests | Which consumers break if I remove or change this field | Whether the values are correct; anything about unregistered consumers |
| Schema-first (OpenAPI, protobuf) | One shared schema | Whether a response is structurally valid; whether a change is wire-compatible | Which fields are actually consumed (OpenAPI: Describing the Contract, Not Designing It) |
| Schema registry with compatibility rules | Versioned schemas plus a compatibility mode | Whether the new schema is backward/forward compatible, mechanically | Semantic breakage — a field whose meaning changed but whose type did not |
| Shared-environment end-to-end | A running system | That one path worked once, in one configuration | Anything the exercised path did not touch; it also drifts from production |
| Production traffic analysis | Access logs of real field usage | What is genuinely used, empirically, right now | What will be used tomorrow; rarely-exercised paths (API Logging Without Leaking) |
The consumer declares what it needs; the producer verifies it
The mechanical shape is simple and the inversion is the interesting part. The consumer writes a test against a stub, and the act of running that test produces an artifact describing what the consumer relied on. The producer then replays that artifact against its real implementation.
Notice what the consumer does *not* assert: fields it does not use. That omission is the signal that lets the producer remove a field safely — and it is why the contract must be generated from real consumer tests rather than written by hand.
- 1Consumer writes a test against a stub
Declares the request it sends and the fields it reads from the response.
fails by Asserting on fields it does not use, over-constraining the producer forever.
- 2Running the test emits a contract
Produces a machine-readable artifact of that interaction.
fails by Hand-writing the contract instead, so it describes intent rather than usage.
- 3Consumer publishes it, tagged with its version
Uploads to a broker or shared repository, associated with the deployed version.
fails by Publishing from every branch, so producers are blocked by experiments.
- 4Producer verifies in its own CI
Replays every published request against the real implementation and checks the declared fields.
fails by Verifying against a mock of itself, which proves nothing.
- 5Build fails on a broken contract
Names the consumer, the interaction and the field that disappeared.
fails by Being a warning, which is ignored, or a nightly job, which is read too late.
- 6Deploy gate checks compatibility
Confirms this producer version satisfies every consumer version currently deployed.
fails by Checking against latest rather than deployed, so a stale consumer still breaks (API Ownership and the Catalog).
Tolerant reading is what makes evolution possible
Contract tests tell you what breaks. What makes most changes not break in the first place is a convention on the consumer side: ignore fields you do not recognise, and do not fail on an unexpected enum value you can treat as unknown.
Without tolerance, every additive change is a breaking change and every deploy needs coordination. With it, additions are free and only removals and type changes need the contract mechanism at all.
// Rejects anything not in the schema, and any unseen enum value.
const Order = z.object({
id: z.string(),
total: z.number(),
status: z.enum(['pending', 'paid']),
}).strict() // <- unknown field => throw
const order = Order.parse(await res.json())
// Producer adds 'currency' -> every request throws.
// Producer adds status 'refunded' -> every refunded order throws.
// Both are additive changes. Both are outages.const Order = z.object({
id: z.string(),
total: z.number(),
status: z.string(), // parse as string; interpret separately
}).passthrough() // <- unknown fields preserved, not fatal
const order = Order.parse(await res.json())
const known = ['pending', 'paid'] as const
type Known = (typeof known)[number]
const status: Known | 'unknown' =
(known as readonly string[]).includes(order.status) ? order.status as Known : 'unknown'
// Producer adds a field -> ignored.
// Producer adds an enum -> falls into 'unknown' and is handled explicitly.
// Producer REMOVES 'total' -> still fails, correctly: the contract test caught it first.Tolerance narrows the set of changes that require coordination down to removals and type changes — exactly the set contract tests are good at catching. A strict consumer makes every addition a coordinated release, which is the cost that makes teams stop evolving the API at all.
How to build it
Most important first.
- Decide the direction. Consumer-driven contracts fit internal services where you can see all consumers; schema-first fits public APIs where you cannot (Public vs Internal APIs).
- Have each consumer generate its expectations from its own tests, so the contract is what the consumer really uses rather than what someone documented.
- Verify every published contract in the producer's pipeline, on every change, and fail the build when one breaks. A contract that is not verified pre-merge is documentation.
- Version contracts against deployed versions, so the producer verifies against the consumers actually running in production — not against whatever is on someone's branch.
- Be tolerant on read: consumers should ignore unknown fields so producers can add without coordination. This one convention removes most of the coordination problem (Backward Compatibility: The Real Rules).
- Cover error responses and status codes in the contract, not just the happy-path body. Consumers branch on error codes, and those are exactly what gets changed casually (The Error Model: Structure Over Apology).
- Apply the same discipline to events: publish an event schema, register consumer expectations, and verify on change (Naming Events).
- Keep contract tests focused on format. Behavioural assertions belong in the producer's own integration tests (A Test Strategy Chosen by What Each Layer Can Prove).
What can go wrong
- Contracts that drift from reality because they are hand-written rather than generated from consumer tests — they then verify a fiction.
- A consumer expectation so specific it pins irrelevant details, so every harmless producer change breaks the build and teams start ignoring it.
- Verification against contracts from consumer branches rather than from deployed versions, so the producer is blocked by an experiment nobody shipped.
- The false-confidence failure: a green contract suite while the producer's behaviour is wrong. Format agreement is not correctness.
- A consumer that reads a field but never asserts on it in its tests, so the field never enters the contract and is removed safely-in-theory and destructively-in-fact.
- The mitigation failing: a schema that permits
additionalPropertiesfreely and types everything loosely, so it passes everything and catches nothing. - Events with unknown consumers — an external team consuming your topic without registering anything. The contract mechanism cannot see them (Event-Driven Backends).
- Producer and consumer deploy independently, so there is always a window where versions are mismatched. Contracts must hold across that window, which is why additive-then-remove sequencing exists (Expand and Contract Migrations).
- A consumer publishing a new expectation while the producer is mid-deploy can fail verification against a version that is on its way out. Verify against deployed versions, not against the newest branch.
- Contracts encode authentication and authorization expectations too. A consumer relying on a scope means removing that scope is a breaking change (Scopes: Least Privilege as Contract Surface).
- Contract fixtures are frequently committed to shared repositories — never put real tokens, real customer data or real identifiers in them (Secrets Are Not Configuration).
- A contract that documents your internal API structure is sensitive if the repository is broadly readable. It is a precise map of your internal surface (Attack Surface).
- Verify error-response contracts specifically, because that is where internals leak. A contract asserting an error body has only
code,messageandcorrelationIdprevents a stack trace being added later (Not Leaking Your Internals).
- "Contract tests replace integration tests." They prove format agreement. Whether the producer computes the right answer is untouched.
- "A schema is a contract." A schema says what is allowed. It cannot tell you which fields are actually used, which is the question every removal asks.
- "Contract tests are end-to-end tests, done cheaply." They deliberately never run both sides together. That is the design, and it is also the limit.
- "If contracts pass, we can deploy." They pass against the consumer versions that published expectations. An unregistered consumer is invisible.
- "Adding a field is always safe." Only for consumers that tolerate unknown fields. A strict-schema consumer rejects the response (Enum Evolution: The New Value That Broke Old Clients).
Operating it
- Track which consumers verify against which producer version. A consumer whose contract has not been re-verified for months is a coordination gap.
- Log and count actual field usage per consumer where you can — API access patterns are the empirical version of a contract and can catch a field nobody registered (API Logging Without Leaking).
- Alert when a producer deploys a version that no longer satisfies a published contract. Ideally this is a build failure, but a runtime check catches out-of-band deploys.
- Watch consumer error rates immediately after a producer deploy. Contract tests reduce this risk; they do not eliminate it ("What Changed?" — Deploy Markers and the Invisible Deploys).
- At a handful of services, contract tests may be more machinery than they are worth — end-to-end tests still run and the teams talk to each other daily.
- Above roughly ten services, shared-environment end-to-end testing stops being feasible and contract tests become the only affordable way to know a change is safe.
- With many consumers per producer, the verification matrix grows and needs a broker to track which contract belongs to which deployed version (API Ownership and the Catalog).
- For public APIs, contracts flip to schema-first plus a deprecation policy, because the consumers are unknown and cannot publish expectations (Deprecation as a Process, Not a Label).
- Contract testing is infrastructure: a broker or shared repository, publishing steps in consumer pipelines, and verification steps in producer pipelines.
- It only covers format. Teams routinely over-trust it and reduce integration testing on the strength of a green contract suite.
- Consumer-driven contracts require every consumer to participate. One team that does not publish is a blind spot in an otherwise trusted mechanism.
- They add coupling to the development process — the producer's build can be broken by a consumer's expectation, which is the point and is still friction.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- SCALE-SPECIFICFor a monolith or two or three services owned by one team, contract testing is machinery you do not need — integration tests still cover it and coordination is a conversation. The value appears when consumers are owned by other teams, deploy independently, and cannot all be run together.
- FRAMEWORK-SPECIFICPact is the common consumer-driven implementation and introduces a broker that tracks which contract version belongs to which deployed version — that bookkeeping is most of what it provides. Spring Cloud Contract inverts the direction, starting from producer-defined contracts that generate consumer stubs. Schema registries for Avro and protobuf enforce compatibility rules at publish time instead. Each answers a different question; do not assume they are substitutes.
- PROTOCOL-SPECIFICgRPC and protobuf carry compatibility rules in the format itself — field numbers, reserved tags, wire-compatible type changes — so a schema registry can mechanically reject an incompatible change. JSON over HTTP has no such rules, which is why explicit contracts do more work there.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — contract-test maturity, broker operation and how compatibility gating fits into a deployment pipeline.