Consumer-driven contracts
Clients write down what they use, the provider checks it, and changes nobody uses stay free.
Provider-driven contracts have a blind spot: the spec says what the API offers, not what anyone uses. When the provider wants to remove a field, the spec can only say "this is breaking", never "and nobody cares". So teams either never remove anything, or remove things and hope. Consumer-driven contracts flip it around. Each consumer writes down the requests it makes and the parts of each response it actually relies on, and the provider checks it still meets every one of them. This lesson builds that, with no special tools.
A consumer's contract
Here is the contract for an imagined shop frontend. It lists four interactions, the requests the frontend makes:
{
"consumer": "shop-frontend",
"provider": "testingapis",
"interactions": [
{
"description": "the product page shows a product",
"request": { "method": "GET", "path": "/api/Products/1" },
"response": {
"status": 200,
"body": { "id": "integer", "title": "string", "price": "number", "inStock": "boolean", "rating": { "rate": "number" } }
}
},
{
"description": "the product page explains a missing product",
"request": { "method": "GET", "path": "/api/Products/9999" },
"response": { "status": 404, "body": { "message": "string" } }
},
{
"description": "the menu lists the categories",
"request": { "method": "GET", "path": "/api/Products/categories" },
"response": { "status": 200, "body": ["string"] }
},
{
"description": "a category page lists its products",
"request": { "method": "GET", "path": "/api/Products/category/books" },
"response": { "status": 200, "body": [{ "id": "integer", "title": "string", "price": "number" }] }
}
]
}- Each interactionhas a description in the consumer's words, the request, and what it expects back.
- Bodies describe shapes, not values.
"price": "number"means any number. The frontend doesn't care which product it gets, only that the fields it shows are there with the right types. - Only the fields the consumer uses are listed. The product page shows the title, the price, the stock and the rating, so the contract lists those. It never shows
image,descriptionorcreatedAt, so they're not there, and the provider is free to change them as far as this consumer is concerned. - An array with one shape inside, such as
["string"], means every item must match that shape.
The consumer team owns this file and keeps it next to its own code, updating it whenever the frontend starts or stops using a field. The provider team receives a copy from every consumer.
Checking the provider against it
Provider verification replays each interaction against the real API and checks the response against the expected shape:
// Consumer contracts: a client writes down the requests it makes and the parts of each response it uses. The
// provider checks it still meets them. A field the client doesn't list can change freely.
//
// Body expectations are shapes, not values: "string", "integer", "number", "boolean", an object of shapes, or an
// array holding one shape that every item must match. Extra fields in the response are ignored.
import { send } from "./api.mjs";
import { findOperation, resolveRef } from "./openapi.mjs";
/** Sends each interaction's request to the real API and checks the response against what the consumer expects. */
export async function verifyAgainstApi(contract) {
const results = [];
for (const interaction of contract.interactions) {
const { method, path, body } = interaction.request;
const response = await send(method, path, { body });
const problems = [];
if (response.status !== interaction.response.status) {
problems.push(`expected status ${interaction.response.status}, got ${response.status}`);
} else if (interaction.response.body !== undefined) {
problems.push(...matchShape(interaction.response.body, response.body, "$"));
}
results.push({ description: interaction.description, problems });
}
return results;
}
/** Checks a value against a shape and returns what's wrong. */
export function matchShape(shape, value, at) {
if (typeof shape === "string") {
const ok = shape === "integer" ? Number.isInteger(value) : typeof value === shape;
return ok ? [] : [`${at}: expected ${shape}, got ${value === null ? "null" : Array.isArray(value) ? "array" : typeof value}`];
}
if (Array.isArray(shape)) {
if (!Array.isArray(value)) return [`${at}: expected an array`];
return value.flatMap((item, i) => matchShape(shape[0], item, `${at}[${i}]`));
}
if (value === null || typeof value !== "object" || Array.isArray(value)) return [`${at}: expected an object`];
return Object.entries(shape).flatMap(([name, inner]) =>
name in value ? matchShape(inner, value[name], `${at}.${name}`) : [`${at}: missing "${name}"`],
);
}
/**
* Checks a contract against a version of the spec, without calling the API: does the spec still promise every
* field the consumer uses, with the type it expects? Run it against a proposed spec to see whether a change breaks
* this particular consumer.
*/
export function verifyAgainstSpec(contract, spec) {
return contract.interactions.map(({ description, request, response }) => {
const found = findOperation(spec, request.method, request.path);
if (!found) return { description, problems: [`${request.method} ${request.path} is no longer in the spec`] };
const documented = found.operation.responses?.[String(response.status)];
if (!documented) return { description, problems: [`status ${response.status} is no longer documented`] };
const schema = documented.content?.["application/json"]?.schema;
const problems = response.body === undefined ? [] : schema ? promises(response.body, schema, spec, "$") : ["the response body is no longer described"];
return { description, problems };
});
}
function promises(shape, schema, spec, at) {
const s = schema.$ref ? resolveRef(spec, schema.$ref) : schema;
if (typeof shape === "string") {
const compatible = s.type === shape || (shape === "number" && s.type === "integer");
if (!compatible) return [`${at}: the consumer expects ${shape}, the spec says ${s.type ?? "anything"}`];
return s.nullable ? [`${at}: the consumer expects ${shape}, but the spec allows null`] : [];
}
if (Array.isArray(shape)) {
if (s.type !== "array" || !s.items) return [`${at}: the consumer expects an array`];
return promises(shape[0], s.items, spec, `${at}[]`);
}
return Object.entries(shape).flatMap(([name, inner]) => {
if (!s.properties?.[name]) return [`${at}: the consumer uses "${name}", which the spec no longer has`];
if (!(s.required ?? []).includes(name)) return [`${at}: the consumer uses "${name}", which the spec doesn't guarantee`];
return promises(inner, s.properties[name], spec, `${at}.${name}`);
});
}matchShape is deliberately looser than the validator from lesson 3. It checks only the listed fields, and ignores everything else in the response, because a consumer is only hurt by changes to what it uses.
test("shapes match types, not values, and ignore fields the consumer doesn't use", () => {
assert.deepEqual(matchShape({ id: "integer", title: "string" }, { id: 7, title: "Lamp", colour: "red" }, "$"), []);
assert.deepEqual(matchShape({ id: "integer" }, { id: "7" }, "$"), ["$.id: expected integer, got string"]);
assert.deepEqual(matchShape(["string"], ["a", 2], "$"), ["$[1]: expected string, got number"]);
});A small script runs the verification, which is what CI calls:
$ node scripts/verify-consumer.mjs contracts/shop-frontend.json
shop-frontend -> testingapis
ok the product page shows a product
ok the product page explains a missing product
ok the menu lists the categories
ok a category page lists its productsChecking a proposed change before it ships
The real payoff comes before a change is released. verifyAgainstSpec checks a contract against a version of the spec, without calling the API: does the spec still promise every field this consumer uses, with the type it expects, always present and never null? Run it against the spec a pull request would produce, and you know which consumers the change would break. Here, two proposed changes:
test("removing a field this consumer doesn't use doesn't break it", () => {
const next = structuredClone(spec);
const product = next.components.schemas.ProductResponse;
delete product.properties.image;
product.required = product.required.filter((n) => n !== "image");
for (const { problems } of verifyAgainstSpec(contract, next)) assert.deepEqual(problems, []);
});test("removing a field it does use breaks it, and says which interactions", () => {
const next = structuredClone(spec);
const product = next.components.schemas.ProductResponse;
delete product.properties.price;
product.required = product.required.filter((n) => n !== "price");
const broken = verifyAgainstSpec(contract, next).filter((r) => r.problems.length);
assert.deepEqual(broken.map((r) => r.description), ["the product page shows a product", "a category page lists its products"]);
assert.deepEqual(broken[0].problems, ['$: the consumer uses "price", which the spec no longer has']);
});Removing imageis a breaking change by lesson 6's rules, and the diff would flag it. But this consumer doesn't use it, so for this consumer it's safe. If every consumer's contract agrees, the field can go. Removing pricebreaks two of the frontend's interactions, and the check names them in the consumer's own words, which tells the provider team exactly who to talk to.
| Provider-driven (the spec) | Consumer-driven (contracts) | |
|---|---|---|
| Written by | The provider | Each consumer |
| Describes | Everything the API offers | What each consumer uses |
| Removing an unused field | Flagged as breaking | Passes, if no contract uses it |
| Needs | An accurate spec | Every consumer to keep its contract up to date |
| Works for public APIs | Yes | No: you can't collect contracts from unknown clients |
How dedicated tools do it
Pact is the best-known tool for consumer-driven contracts, with libraries for most languages. The idea is the same as here, with more machinery:
- The consumer's tests run against a mock provider, and the interactions they make are recorded into a contract file automatically, so it can't drift from what the consumer really does.
- Contract files are published to a shared server, a broker, so providers always fetch the latest versions.
- The provider's build replays every consumer's interactions against the real provider, with a way to set up the data each one needs.
- Before deploying either side, a check asks the broker whether the versions about to run together are compatible.
When it fits
Consumer-driven contracts work best when you know all your consumers and can get them to maintain contracts: services inside one company, or a few close partners. For a public API like this one, with consumers nobody knows, the published spec and lesson 6's strict rules are the only contract available. Many teams use both: the spec for everyone, and consumer contracts for the internal clients that matter most.
Check your understanding
1.Why does a consumer contract list only some of the fields in a response?Show answer
It lists only what the consumer uses. Fields it doesn't use can change without affecting it.
2.What is provider verification?Show answer
Replaying each consumer's interactions against the real provider and checking the responses match the expected shapes.
3.Removing image is breaking by the rules of lesson 6. Why can it still be safe?Show answer
If no consumer's contract uses image, removing it breaks no actual client.
4.Why doesn't consumer-driven testing work for a public API?Show answer
You can't collect contracts from consumers you don't know about.
Exercise
Write a second contract, for a todo app that lists a user's todos and creates new ones, using GET /api/Todos and POST /api/Todos. For the create, include a request body, and clean up the todo it creates afterwards. Then use verifyAgainstSpec to find out which of these proposed changes would break it: removing dueDate from TodoResponse, and making priority nullable.