What is a consumer-driven contract, and what is it not?
A consumer-driven contract is a record of how one service actually uses another. The consumer writes it, and it captures two things: the exact requests the consumer sends (method, path, query, headers, body) and the response fields the consumer reads. If an orders service calls GET /customers/{id} and only uses id, email and status, the contract contains those three fields and nothing more. The provider can add fields, reorder them or change ones no consumer reads, and every contract still passes.
This narrowness is what makes the contract useful. A full OpenAPI schema describes everything a provider offers. A contract describes what a consumer depends on. Only the second tells a provider whether a given change will break someone in production.
A contract is not a test of provider behaviour. It checks that a response has the right shape and status code. It does not check that the right customer came back, that a discount was calculated correctly, or that a write was persisted. Those checks stay in the provider's own unit and functional tests.
Contract tests on their own catch little that a well-maintained integration suite would not also catch. The value comes from three parts working together:
- Contracts that record what each consumer depends on.
- A Pact Broker that stores and versions them.
can-i-deploy, which answers "is this exact version compatible with what is running in production?" before each release.
Who should use it?
Pact pays off most for backend and platform teams that run several independently deployed services owned by different teams. In that setup, nobody can run the full system for every change, and releases are not coordinated. Pact is a good fit when most of these conditions hold:
- The consumers are known and internal. Every consumer can be asked to write and maintain tests, because the teams work in the same organisation.
- Services deploy on their own schedules. If everything ships together as one release, a shared integration suite already sees the matching versions, and the broker's version matrix adds little.
- Both sides have CI pipelines they control. The consumer must publish pacts, and the provider must run verification and publish results. A workflow that only one side follows does not protect anyone.
- Interfaces change often enough to break. A provider that changes rarely and has one consumer may be cheaper to protect with a few integration tests.
Within those teams, the work splits by role:
- Consumer developers write the interaction tests alongside their client code.
- Provider developers own verification and the provider state handlers.
- A platform or developer-experience team usually runs the broker, maintains shared pipeline templates and keeps deployment recording consistent across services.
Pact is a poor fit for public APIs and third-party consumers. The section on when to avoid Pact covers this in more detail.
How does it work? Consumer, broker and provider
When no one can run the whole system for each change, teams need another way to check compatibility before release. The Pact workflow fills that gap. It has four parts, and each one lets a team check whether a release is safe without running the other team's code.
-
The consumer test produces a pact. The consumer's tests run against a Pact mock server instead of the real provider. Each interaction the tests exercise is recorded into a JSON pact file. An interaction is the request plus the parts of the response the consumer actually reads. Because the file is generated from passing tests, it describes what the code really uses, not what a spec says it should use.
-
The pact is published to the Pact Broker. CI publishes the pact with the consumer's version, usually the git commit SHA, and its branch. A commit SHA ties the contract to one exact build, which the later steps depend on.
-
The provider verifies it. When a new or changed pact arrives, a broker webhook triggers a provider build. That build replays each recorded request against the real provider and checks the responses. The pass or fail result is published back to the broker for that specific provider version.
-
The broker builds a compatibility matrix. Each result becomes a row in the matrix, stating whether consumer version X works with provider version Y. Before a release,
can-i-deployasks the matrix whether this version is compatible with everything already running in the target environment. To answer, the broker needs to know what is running there. Recording each deployment supplies that, so the check compares real deployed versions instead of "latest against latest".
+------------------+ +------------------------+ +------------------+
| CONSUMER CI | | PACT BROKER | | PROVIDER CI |
| | | | | |
| 1. Tests run vs | | 2. Pacts stored by | | 3. Replay each |
| Pact mock | | consumer version | | request vs |
| server | | (git SHA) + branch | | real provider |
| | | | | | |
| v | pub- | | web- | |
| pact (JSON) | lish | | hook | |
| |----->| |----->| |
| | | | | |
| | | 4. Compatibility |result| |
| | | matrix: consumer X |<-----| Publish pass/ |
| | | x provider Y -> ok? | | fail for this |
| | | | | provider version |
+------------------+ +------------------------+ +------------------+
^ | ^
can-i-deploy | | yes/no | record deployment
| v |
+-------------------------------------------+
| RELEASE PIPELINE (per environment) |
| check: is this version compatible with |
| what is deployed? then deploy and record |
+-------------------------------------------+
The consumer publishes a pact to the broker, a webhook triggers provider verification whose result fills the compatibility matrix, and each release queries that matrix with can-i-deploy and records its deployment.
How do you write a consumer test that produces a pact file?
Take an order service client with one method, getOrder(id), that calls GET /orders/{id} and maps the JSON into a domain object. The consumer test starts Pact's mock provider on a local port and declares one interaction: given the provider state "order 42 exists", a GET to /orders/42 with Accept: application/json returns 200 and a body. The test then points the real client at the mock and calls getOrder(42).
Calling the real client matters. If the test builds the HTTP request by hand, the contract describes the test and not the client, and drift goes unnoticed.
When the test passes, Pact writes the interaction to a pact file. If the client sends a request the mock did not expect, or never sends the declared request, the test fails and no contract is written.
The common failure mode is over-specification. Here is an over-specified interaction:
- It asserts the exact
id,status: "SHIPPED", andcreatedAttimestamp. - It includes all twenty fields of the provider's response.
- It adds a second interaction for a query parameter the client never sends.
That contract breaks whenever test data changes. It also blocks the provider from removing fields that no consumer reads.
A lean interaction avoids those problems:
- It uses type matchers, so
idis any integer andstatusis any string, or a regex if the consumer branches on specific values. - It lists only
id,status, anditems, which are the fields the mapper actually reads. - It uses an array matcher for
itemsthat requires at least one element of the right shape.
The lean contract asserts what the consumer needs and nothing more.
import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { OrderClient } from '../src/orderClient'; // the real client under test
const { integer, string, regex, eachLike } = MatchersV3;
const provider = new PactV3({
consumer: 'checkout-web',
provider: 'order-service',
dir: path.resolve(process.cwd(), 'pacts'), // pact file is written here when the test passes
});
describe('OrderClient.getOrder', () => {
it('maps an existing order', () => {
provider
.given('order 42 exists')
.uponReceiving('a request for order 42')
.withRequest({ method: 'GET', path: '/orders/42', headers: { Accept: 'application/json' } })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
// Lean body: only the fields the mapper reads, matched by type/shape, not exact value.
body: {
id: integer(42),
// regex because the consumer branches on these specific values
status: regex('PENDING|SHIPPED|CANCELLED', 'SHIPPED'),
// at least one item with this shape; extra provider fields are ignored
items: eachLike({ sku: string('SKU-123'), quantity: integer(1) }, 1),
},
});
return provider.executeTest(async (mockServer) => {
// Point the real client at the mock so the contract describes the client, not the test.
const client = new OrderClient(mockServer.url);
const order = await client.getOrder(42);
expect(order.id).toBe(42);
expect(order.items.length).toBeGreaterThan(0);
});
});
});
A pact-js V3 consumer test that drives the real OrderClient against the Pact mock provider and declares a lean GET /orders/42 interaction using type, regex and array matchers for only the fields the client reads.
How does provider verification work, and why are provider states hard?
On the provider side, the Pact verifier takes each interaction from the pact file and replays it against a real, running instance of the provider. Each interaction goes through four steps:
- Read the provider state. This is the "given" clause the consumer wrote, such as "an order with id 42 exists".
- Set up that state. The verifier calls a state handler on the provider to put the system into that situation.
- Replay and compare. The verifier sends the recorded request and compares the actual response against the expected one using the consumer's matchers. Extra fields are allowed. Missing fields, wrong types or a wrong status code fail the interaction.
- Publish the result. The verifier can publish results back to the broker, where they become the input to deployment decisions.
A typical state handler for "an order with id 42 exists" inserts an order row with that id and one line item. It also stubs the pricing client the order endpoint calls, so the response does not depend on another service being up.
Maintaining these handlers is usually the hardest part of Pact. Each new consumer test tends to add a new state string. Each handler grows into fixture code that writes directly to tables and wires up fakes for the provider's own downstream services. A schema migration or a new dependency can then break verification even though the API contract never changed.
Three habits keep this manageable:
- Keep states few. Reuse them across interactions instead of creating one per test.
- Name states by business situation. Use names like "customer has an overdue invoice", not "row in invoices with status=3".
- Stub at the provider's boundaries. Stub the repository and outbound clients rather than seeding a full database and calling live dependencies. This keeps verification fast and limits what can break it to the HTTP contract.
import { Server } from 'http';
import { Verifier } from '@pact-foundation/pact';
import { buildApp } from '../src/app';
import { InMemoryOrderRepo } from './fakes/in-memory-order-repo';
import { FakePricingClient } from './fakes/fake-pricing-client';
// Replace the provider's own boundaries (persistence, outbound HTTP) with fakes,
// so only the HTTP contract can break verification.
const orders = new InMemoryOrderRepo();
const pricing = new FakePricingClient();
describe('order-service provider verification', () => {
let server: Server;
beforeAll((done) => { server = buildApp({ orders, pricing }).listen(8081, done); });
afterAll((done) => { server.close(done); });
it('satisfies every consumer pact in the broker', () =>
new Verifier({
provider: 'order-service',
providerBaseUrl: process.env.PROVIDER_BASE_URL, // base URL of the instance started above (port 8081)
pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
providerVersion: process.env.GIT_SHA,
publishVerificationResult: process.env.CI === 'true', // feeds can-i-deploy
// Few states, named by business situation, reused across interactions.
stateHandlers: {
'an order with id 42 exists': async () => {
orders.reset();
orders.add({ id: 42, status: 'PLACED', lines: [{ sku: 'SKU-1001', qty: 1 }] });
pricing.willReturn('SKU-1001', { unitPrice: 19.99, currency: 'EUR' });
},
'no orders exist': async () => {
orders.reset();
},
},
}).verifyProvider());
});
A Pact JS provider verification test whose state handlers seed an in-memory order repository and a fake pricing client instead of a real database and live downstream service, then publish results to the broker from CI.
How do you use the Pact Broker and can-i-deploy as a CI/CD gate?
Once verification runs in isolation, wire it into a pipeline where the broker decides what is safe to ship. Exact CLI flags differ between versions of the pact-broker tool, so check your version's help output for each command.
Publish with version and branch. The consumer pipeline runs pact-broker publish with the pact directory, a consumer version and the branch name. Use the git commit SHA as the version, such as <CONSUMER_SHA>. A commit SHA ties each pact to exactly one build, and the branch lets the provider ask for "whatever is on main" instead of a hard-coded version.
Trigger verification on change. Configure a broker webhook that starts the provider's verification job only when a published pact's content changes. If the consumer republishes an identical pact, the existing result still applies and no job runs.
Verify the right set. The provider verifies pacts from two sources: the consumer's main branch, and every consumer version currently deployed or released. That covers what is coming next and what is running now.
Gate every deploy. Before either side deploys, run pact-broker can-i-deploy for the pacticipant, version and target environment, such as <ENV>. Say a consumer adds an expectation the provider has not implemented. That pact has no successful verification, so the consumer's deploy is blocked until the provider ships the change and verification passes.
Record what runs where. After each successful deploy, run pact-broker record-deployment. can-i-deploy only checks against the versions the broker believes are in each environment. If one pipeline skips this step, the broker's view goes stale. The gate can then pass a change that breaks real traffic, or block one that is safe.
#!/usr/bin/env bash
set -euo pipefail
export PACT_BROKER_BASE_URL="<BROKER_URL>" # your broker's base URL, from CI config
export PACT_BROKER_TOKEN="<BROKER_TOKEN>" # inject from CI secrets, never commit
VERSION="$(git rev-parse HEAD)" # one pact per build: the commit SHA
BRANCH="$(git rev-parse --abbrev-ref HEAD)" # lets the provider select 'main'
ENV="production"
pact-broker publish ./pacts \
--consumer-app-version "$VERSION" \
--branch "$BRANCH"
pact-broker can-i-deploy \
--pacticipant order-web \
--version "$VERSION" \
--to-environment "$ENV" \
--retry-while-unknown 10 \
--retry-interval 30
./deploy.sh "$ENV" "$VERSION"
pact-broker record-deployment \
--pacticipant order-web \
--version "$VERSION" \
--environment "$ENV"
A consumer pipeline script that publishes pacts with the commit SHA and branch, blocks on can-i-deploy for the target environment, deploys, and then records the deployment so the broker knows what runs where.
Where does a breaking change get caught? A field rename walkthrough
Take an order-service provider whose response includes customerName. The billing consumer reads that field, and its pact records the expectation. A provider engineer renames it to customer.fullName and opens a pull request.
What happens on the provider's pull request
- CI fetches the pacts for consumer versions currently deployed, then replays each interaction against the branch build.
- The interaction "a request for order 42", given the provider state "order 42 exists", fails. The expected path
$.customerNameis missing from the actual body. - The failure names the consumer, the interaction and the field. Nobody has to bisect logs in staging or work out which of several recent merges caused it.
The change is blocked before merge. The fix is a design decision, not an incident.
The safe path
- The provider adds
customer.fullNamealongsidecustomerNameand ships that. - Billing switches to the new field, publishes a new pact and deploys.
- The provider removes
customerNameonce can-i-deploy reports that no deployed consumer version still expects it.
Illustrative estimate
All inputs here are assumed:
- Staging breakages caught by Pact instead: 4 per year, each costing 3 engineers × 4 hours. That is 48 hours per year.
- Up-front work for this one consumer–provider pair: about 50 hours. This covers consumer tests, state handlers and pipeline wiring.
- Upkeep: roughly 2 hours per month, or 24 hours per year.
On these assumptions, one pair barely breaks even in the first year. The economics improve as more consumers verify against the same provider, because state handlers and pipeline setup get reused.
How can it save engineering time?
The time savings come from moving compatibility failures earlier and making them specific. They do not come from writing fewer tests.
Failures surface in the build that caused them. Without contracts, an incompatible change is often found in a shared staging environment, after several teams have merged. Someone then has to work out which change broke which caller. With Pact, provider verification fails on the provider's own pull request, and the failure names the consumer, the interaction and the field. The engineer who made the change fixes it while the context is fresh.
Less time spent on shared environments. Shared-environment integration tests are slow to run and compete for the same environment. They also fail for reasons unrelated to the change, such as unavailable dependencies or leftover data. Contract tests run in each service's own CI against a mock or a single provider instance. Teams can then keep a smaller set of integration and end-to-end tests for the things contracts cannot see, instead of using them as the main compatibility check.
No coordinated release trains. can-i-deploy answers "is this version compatible with what is deployed in this environment?" from recorded verification results. Teams stop scheduling joint releases or asking in chat whether the other side has shipped yet. A consumer that needs a new provider field is blocked automatically until the provider deploys it, and then it is unblocked without a meeting.
Safer removal of old fields. Providers often keep unused fields indefinitely because nobody knows who still reads them. Pacts record which consumers read which fields. A provider can therefore remove a field once can-i-deploy shows no deployed consumer depends on it. This keeps the API surface smaller and cheaper to maintain.
Cheaper verification when nothing changed. Webhooks trigger provider verification only when pact content changes. A republished identical pact reuses the existing result, so CI time is not spent re-verifying unchanged contracts.
These savings come with ongoing costs, as the illustrative estimate in the previous section shows: writing consumer tests, maintaining provider state handlers, running the broker, and keeping deployment recording accurate. The net saving depends on how often cross-service breakages happen today and how many consumer–provider pairs share the setup. It is worth estimating from your own incident history before adopting Pact widely.
When should you avoid Pact, and what are the alternatives?
Pact protects only the interactions someone has written a consumer test for. With a public API or third-party consumers you cannot see, there is no consumer test, so there is no contract. Pact also depends on both teams adopting the workflow. If provider verification failures are routinely overridden, the pacts become unenforced documentation.
Other limits to weigh before adopting it:
- Over-specification. Consumers that assert exact values, or fields they never read, cause false failures that erode trust in the gate.
- Fragile provider states. State handlers drift as the provider's data model evolves.
- Shared infrastructure. If the broker is unavailable, can-i-deploy cannot answer, and pipelines block.
- Stale records. can-i-deploy reasons from recorded deployments. A skipped recording step produces wrong answers.
- Blind spots. All of these pass contract tests:
- Latency problems.
- Auth, TLS and network misconfiguration.
- Semantic changes where the shape is unchanged. For example,
priceswitching from cents to dollars still passes.
- Messaging. Asynchronous contracts need explicit design for message shape and versioning.
| Approach | Catches | Feedback speed | Maintenance cost | Fit |
|---|---|---|---|---|
| Pact (consumer-driven) | Shape mismatches in fields consumers actually use | Fast, per build | Medium–high, needs two-team buy-in | Internal APIs, known consumers |
| Schema/spec validation | Drift from a published spec | Fast | Low | Public and internal |
| Provider-driven contracts | Breaks against the provider's declared contract | Fast | Low–medium | Public APIs, many consumers |
| Shared-env integration tests | Config, auth, real dependencies | Slow | High, due to environment contention | Internal |
| End-to-end tests | Whole user journeys | Slowest | Highest, often flaky | Few critical paths |
These approaches complement each other. Use Pact for the internal interactions you control, and use spec validation where you cannot enumerate the consumers.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to pact contract testing microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.