What consumer-driven contracts are and what they are not
A consumer-driven contract is a record of the requests a consumer actually sends to a provider and the parts of each response it actually reads. If an order service calls GET /customers/{id} and only uses id, email and status, the contract covers that request and those three fields, nothing else. The consumer's own test suite generates it, so it describes what the consumer depends on, not what the provider documents. The provider can then add fields, reorder them or change endpoints nobody calls without breaking anything. A contract fails only when a change would break a real caller.
That narrow scope is also the limit. A contract checks structure and types, plus matching rules such as "this is an ISO 8601 date." It does not check whether status: "active" means what the consumer thinks it means. It does not check whether a discount was calculated correctly or what happens after a sequence of calls. Business logic and field semantics still need the provider's own functional tests. Critical paths such as checkout or payments still need a small set of end-to-end tests.
Teams adopt contracts because they catch integration breakage before deployment, without a shared integration environment where every service has to run at the right version at the same time. Each side tests in isolation, against the contract.
A passing consumer test only proves that the consumer agrees with itself. Contracts become a release decision only after the provider has verified them, the results are stored in a Pact Broker, and can-i-deploy checks those results before anything ships.
How the consumer-to-provider-to-deploy loop works
The loop has five steps. Each one writes a fact into the broker, and the broker's compatibility matrix is built from those facts.
-
The consumer test generates a pact. The consumer's unit tests run against a Pact mock server. Every interaction the code exercises is written to a pact file, which lists the requests the consumer sends and the responses it relies on.
-
The pact is published with a version and a branch. CI uploads the file to the Pact Broker. The consumer version is the git SHA, so a pact maps to exactly one build. The branch name lets the provider's CI choose which consumer lines to verify, such as
mainor a feature branch. -
A webhook triggers provider verification. When the broker receives a pact whose content has changed, a webhook starts a provider build. That build replays each interaction against the real provider, using provider states to seed the required data.
-
Verification results are published back. The provider build reports pass or fail, tagged with the provider's own git SHA. That result fills in one cell of the matrix: this consumer version against this provider version.
-
can-i-deployqueries the matrix, and deployments are recorded. Before a release, the pipeline asks whether a given version is compatible with the versions currently deployed in the target environment. After a successful release, recording the deployment tells the broker which version now runs in that environment.
Each step depends on the ones before it, so skipping any of them leaves gaps in the matrix:
- If a team doesn't publish verification results, the matrix has no data to answer from.
- If a team doesn't record deployments, the broker can't tell which versions are running in an environment.
In both cases can-i-deploy either blocks every release or answers a question nobody asked.
CONSUMER CI PACT BROKER PROVIDER CI
+------------------+ +--------------------------+ +------------------+
| 1. unit tests vs | | pacts | | 3. replay each |
| Pact mock |-2-->| consumer SHA + branch |-3-->| interaction |
| server | | verification results |<-4--| vs real API |
| -> pact file | | provider SHA, pass/fail | | + prov states |
+------------------+ | deployments per env | +------------------+
| +------------------+ |
| | COMPATIBILITY | |
| | MATRIX | |
| +--------+---------+ |
+------------+---------+---+
query | ^ record
v |
+--------------------------------+
| 5. can-i-deploy: query matrix |
| for version + target env |
| deploy -> record deployment |
| (env, version) to broker |
+--------------------------------+
2 = publish pact 3 = webhook triggers verification 4 = publish result
Each step writes a fact into the Pact Broker (pact, verification result, deployment) and can-i-deploy answers only from the matrix those facts build, so a skipped step leaves gaps in the matrix.
Writing a consumer test and publishing the pact
A consumer test does three things. It declares the interaction the consumer expects, runs the real client code against the Pact mock server, and writes the result to a pact file. The test should exercise your actual HTTP client, not a hand-built request. Otherwise the contract describes code that never ships.
Match on shape, assert only what you read
Define the expected response with matchers rather than literals:
- Use a type matcher for an order ID, so the contract says "a string" instead of one specific string.
- Use a regex for a timestamp or status code, which pins the format without pinning the value.
- Use an array matcher for line items, which describes one element's shape instead of a fixed list.
Include only the fields the consumer parses. If the provider returns twenty fields and you read four, the contract lists four. The provider is then free to add, rename or drop the other sixteen.
The trade-off cuts both ways:
- Too strict. Exact literals and unused fields couple the two services. A provider changing a sample value or removing an unused field fails verification, and teams learn to ignore the red build.
- Too loose. A type matcher on a field that must be one of three enum values lets a fourth value through. That breakage surfaces in production. Use regexes where the format carries meaning.
Publish from CI
Archive the generated pact file as a build artifact, so a failed publish or a disputed verification can be traced to the exact file. Then publish it to the broker. Set the consumer version to the commit SHA, because the broker's deploy checks key on versions and a SHA maps one-to-one to the code that produced the contract. Record the branch too, so the provider can select pacts from specific branches.
import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { OrderClient } from '../src/orderClient'; // the real client that ships
const { string, regex, eachLike, integer } = MatchersV3;
// Pact files land in ./pacts; CI archives this directory as a build artifact.
const provider = new PactV3({
consumer: 'order-ui',
provider: 'order-service',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('OrderClient', () => {
it('fetches an order', () => {
provider
.given('order 42 exists')
.uponReceiving('a request for order 42')
.withRequest({ method: 'GET', path: '/orders/42' })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
// List only the fields OrderClient parses; the provider may change the rest.
body: {
id: string('42'), // any string
status: regex('^(PENDING|SHIPPED|CANCELLED)$', 'SHIPPED'), // enum: a type matcher is too loose
createdAt: regex('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$', '2024-05-01T10:00:00Z'),
items: eachLike({ sku: string('SKU-1'), quantity: integer(1) }), // shape of one element
},
});
return provider.executeTest(async (mockServer) => {
const order = await new OrderClient(mockServer.url).getOrder('42');
expect(order.status).toBe('SHIPPED');
expect(order.items[0].quantity).toBe(1);
});
});
});
// CI, after tests pass and ./pacts is archived:
// pact-broker publish ./pacts --consumer-app-version "$GIT_SHA" --branch "$GIT_BRANCH"
A pact-js consumer test using type, regex and array matchers on only the fields the client reads, with the CI publish command that tags the pact with the commit SHA and branch.
Provider verification and provider states in CI
by branch rather than by guesswork.
In provider CI, the verifier asks the broker for pacts using consumer version selectors instead of a hardcoded list. A typical set is the consumer's main branch plus every version currently marked as deployed or released. Main catches upcoming breakage. Deployed and released versions protect what is running in production right now, which is the compatibility that actually matters at release time. The verifier starts the provider, replays each interaction as a real HTTP request, and compares the responses against the pact. It then publishes the results back to the broker under the provider's own commit SHA and branch. Publish only from CI so local runs don't pollute the compatibility matrix.
Each interaction can declare a provider state. Name states after business conditions, such as "a user with an overdue invoice", rather than "invoices table seeded with row 42". The consumer describes what it needs. The provider decides how to make that true, whether through a repository call, an in-memory stub of a downstream dependency, or a domain service. Because the state names carry no storage details, the provider can change its schema without renegotiating contracts.
Watch the state layer as it grows. For providers with complex persistence, state handlers tend to turn into a second fixture suite that is slow, order-dependent and brittle. Keep handlers small, stub dependencies beyond the provider's boundary, and delete states that no pact references anymore.
Finally, configure a broker webhook that fires when a consumer publishes a changed contract needing verification. The webhook triggers a provider build that verifies only that pact. The consumer then gets a result within minutes instead of waiting for the provider's next commit.
import { Verifier } from '@pact-foundation/pact';
import { startApp } from '../src/app'; // provider under test
import { billing } from './test-doubles'; // domain-level test helpers
it('honours all consumer pacts', async () => {
const server = await startApp({ port: 8081 });
// A contract_requiring_verification_published webhook passes the changed pact's URL in.
const pactUrl = process.env.PACT_URL;
await new Verifier({
provider: 'invoice-service',
providerBaseUrl: '',
pactBrokerUrl: '',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
// Webhook build: verify only the changed pact. Normal build: use selectors.
...(pactUrl
? { pactUrls: [pactUrl] }
: { consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }] }),
// Publish only from CI so local runs stay out of the matrix.
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_COMMIT,
providerVersionBranch: process.env.GIT_BRANCH,
// States are named for business conditions; how they become true is the provider's choice.
stateHandlers: {
'a user with an overdue invoice': async () => {
await billing.givenUser('user-123').withInvoice({ dueDaysAgo: 30, paid: false });
},
'a user with no invoices': async () => {
await billing.givenUser('user-456');
},
},
})
.verifyProvider()
.finally(() => server.close());
});
A pact-js provider verification test that uses main-branch and deployed-or-released selectors in normal builds, checks only the changed pact when a webhook supplies PACT_URL, publishes results only from CI under the commit SHA and branch, and sets up business-named provider states.
Gating releases with can-i-deploy and recording deployments
Both pipelines run the same check before their deploy step: can this version go to this environment? can-i-deploy answers by querying the broker's matrix for a successful verification between the candidate version and every counterpart version currently recorded in that environment. If the answer is no, the job stops. This is why the check must sit directly before deployment and not earlier in the pipeline. The environment can change between build and deploy.
After a successful deploy, record it with record-deployment for services where one version replaces another. Use record-release for artifacts like mobile apps, where several versions run side by side. If you skip this step, the matrix describes what you tested, not what is running, and later checks compare against the wrong versions.
Introducing changes safely
- Pending pacts. A new contract that fails verification does not break the provider build until the provider has verified it once. Consumers can publish expectations without blocking the provider team.
- Work-in-progress pacts. These are feature-branch pacts the provider's selectors would not otherwise pick up. They are verified automatically as pending, so consumers get early feedback.
- Expand then contract. To remove a field, the provider first adds the replacement. Consumers then migrate. The provider removes the old field only after
can-i-deployconfirms that no recorded consumer version still expects it.
The broker is production infrastructure
The broker needs a named owning team, monitoring, backups of its database, and scoped tokens. Use read-only tokens for most CI jobs and publish rights only where they are needed. Decide in advance how pipelines behave if the broker is unreachable:
| Policy | Behaviour when broker is down | Risk |
|---|---|---|
| Fail closed | All deploys block | Outage halts releases, including hotfixes |
| Fail open | Deploys proceed unchecked | Incompatible versions can ship |
Many teams fail closed by default and give hotfixes a documented, audited override.
#!/usr/bin/env bash
# Deploy gate for one service. The same script runs in consumer and provider
# pipelines, directly before the deploy step.
set -euo pipefail
# The pact-broker CLI reads these from the environment.
# Inject the token from the CI secret store. Never commit it.
export PACT_BROKER_BASE_URL=""
: "${PACT_BROKER_TOKEN:?missing broker token}"
SERVICE="order-service"
VERSION="${GIT_SHA:?missing git sha}" # the same version that was published
ENVIRONMENT="production"
# 1. Ask the matrix whether this version is compatible with every counterpart
# currently recorded in the target environment. Retry while a provider
# verification is still running, so the result is not reported as unknown.
# A non-zero exit stops the job. Because of set -e, an unreachable broker
# also stops the job, which makes this gate fail closed.
pact-broker can-i-deploy \
--pacticipant "$SERVICE" \
--version "$VERSION" \
--to-environment "$ENVIRONMENT" \
--retry-while-unknown 6 \
--retry-interval 10
# 2. Deploy. Replace this with your real deploy command.
./deploy.sh "$SERVICE" "$VERSION" "$ENVIRONMENT"
# 3. Tell the broker what is now running, so later can-i-deploy checks compare
# against this version. Use record-release instead for artifacts where
# several versions stay live at once, such as mobile apps.
pact-broker record-deployment \
--pacticipant "$SERVICE" \
--version "$VERSION" \
--environment "$ENVIRONMENT"
A fail-closed deploy script that gates the release with can-i-deploy against the target environment, then calls record-deployment so the broker matrix reflects the version actually running.
When to use Pact, when to avoid it, and the alternatives
Pact pays off most for internal services with known consumers and independent deploy cadences. Its value comes from replacing a guess ("will this change break anyone?") with a recorded answer. That only works when every consumer can be enumerated and is willing to publish pacts.
Avoid it in three situations:
- Public APIs with unknown clients. No pact can represent consumers you cannot see, so provider-side compatibility discipline does the real work.
- Small systems that always deploy together. A shared integration test already proves compatibility at lower cost.
- Teams unwilling to let consumers influence the provider's build. Pact makes a consumer's expectations a failing check in someone else's pipeline. Without that agreement, verification failures get skipped rather than fixed.
Message pacts have a narrower scope. They verify payload shape. They do not verify delivery, ordering, retries or broker configuration, which still need separate tests.
| Approach | Coverage | Feedback speed | Coupling |
|---|---|---|---|
| Pact (consumer-driven) | Interactions consumers actually use | Fast, per-commit | Provider build depends on consumer pacts |
| Provider-driven OpenAPI checks | Whole published schema, not real usage | Fast | Low; consumers are not consulted |
| Bi-directional / schema-based | Consumer expectations compared to provider spec | Fast | Moderate; relies on the spec matching the implementation |
| End-to-end environments | Real wiring, config, infrastructure | Slow, often flaky | High; shared environment contention |
| Integration tests with stubs | Whatever the stub encodes | Fast | Low, but stubs drift silently |
A common combination is Pact for internal HTTP and message boundaries, schema checks for public surfaces, and a thin end-to-end suite for infrastructure behaviour that contracts cannot see.
Impact on development cost and engineering time
Pact shifts cost more than it removes it. Setup means adding publish and verify steps to every pipeline and operating a broker. If you self-host, you own its database, authentication, backups and upgrades. The recurring cost is provider states. Each new interaction may need a handler that seeds data deterministically, and those handlers break whenever the provider's schema changes. Coordination also changes shape: teams must agree on state names and on who owns a failed verification.
The less visible risk is false confidence. A green matrix covers only the interactions some consumer recorded. Endpoints no consumer exercises, and error paths such as 409s or malformed payloads that nobody modelled, stay unverified while dashboards look complete.
The savings come from three places. Teams need fewer shared staging environments. Breakage surfaces at pull-request time instead of after a deploy. Release sync meetings become a can-i-deploy query.
Illustrative estimate (assumed numbers, not a benchmark). Assume 12 services, a weekly 45-minute release sync with 8 engineers (about 4.3 per month), 2 staging integration breaks per month at 6 hours each, 120 hours of setup, and 1 hour of maintenance per service per month.
- Monthly saving = (syncs × attendees × hours) + (breaks × hours) = (4.3 × 8 × 0.75) + (2 × 6) ≈ 37.8 h
- Net = 37.8 − 12 = 25.8 h/month
- Payback ≈ 120 / 25.8 ≈ 4.7 months
These practices keep the matrix trustworthy:
- Every pipeline publishes pacts tagged with the commit SHA and branch.
- Providers publish every verification result, including failures.
- Every deploy, and every rollback, records a deployment to its environment.
- Teams treat unverified endpoints as unknown, not as safe.
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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.