What a consumer-driven contract is and how it differs from schema and end-to-end tests

A consumer-driven contract is a record of what one consumer actually depends on. It lists the specific requests the consumer sends and, for each one, the response fields it reads. If an order service calls GET /customers/42 and only uses id, email and status, the contract covers those three fields. The provider can return twenty more without breaking anything.

The narrow scope is what makes contracts useful. An OpenAPI document describes everything a provider offers. A response can pass schema validation while a consumer still fails, for example because a field it needs became optional. In the other direction, a schema diff flags a change to a field that nobody reads. A contract flags a change only when a real consumer would notice it. That means you can check compatibility per consumer, before deploying, without running the other service.

End-to-end tests catch integration problems too, but they need a whole environment deployed. They are slow and flaky, and when one fails it is hard to tell which service caused it.

Approach What it checks Who owns it When it runs Failures it catches
Consumer-driven contract Requests and fields each consumer uses Consumer writes it, provider verifies it Unit-test stage of each service's CI Breaking changes for a specific consumer
Schema validation Full API surface against a spec Provider Build or request time Responses that drift from the published spec
End-to-end test User flows across deployed services Shared or QA team After deployment to an environment Wiring, config and data issues across the system

These three approaches answer different questions, so they work alongside each other.

How Pact works end to end: consumer, provider, and the Pact Broker

Pact splits the work across two pipelines that never call each other directly. The Pact Broker sits between them and holds the shared state.

 Consumer CI                  Pact Broker                 Provider CI
 -----------                  -----------                 -----------
 run consumer tests
 against Pact mock  ──pact──▶ store pact for
 -> pact file (JSON)          consumer version + branch
                                      │
                                      └───── fetch pacts ──▶ replay requests against
                                                             real provider build
                              store verification ◀─result── publish pass/fail
                              for provider version
                                      │
 deploy step  ──can-i-deploy?──▶ query matrix
              ◀──yes / no─────
 record deployment ─────────▶ mark version as
                              deployed to environment

The consumer's tests run against a local Pact mock server. Each interaction the consumer exercises is written to a pact file, and only those interactions. CI publishes that file tagged with the consumer's version, usually the git commit SHA, and its branch. In provider CI, the build fetches the relevant pacts, replays each request against the running provider and compares the responses. It then publishes the result for its own version.

The Broker records every pact and every verification result against specific versions. This forms a matrix of which consumer versions have been verified against which provider versions. Because the Broker also knows which versions are deployed to each environment, can-i-deploy can answer a narrow question before a release: has this version been verified against everything currently running in the target environment?

Before the Broker, teams commonly emailed pact files around or committed them to each other's repositories. Those copies drifted out of date and carried no version history. The Broker replaces them with a single source of truth that both teams' pipelines query automatically.

+------------------+      +------------------+      +------------------+
|   Consumer CI    |      |   Pact Broker    |      |   Provider CI    |
+------------------+      +------------------+      +------------------+
| run tests vs     |      | stores pacts per |      | fetch pacts      |
| Pact mock server | pact | consumer version |fetch | for this         |
| -> pact file     |----->| + branch         |----->| provider         |
| (JSON)           |      |                  |      | replay requests  |
+------------------+      | stores results   |result| vs real build    |
                          | per provider     |<-----| publish pass/    |
                          | version          |      | fail + version   |
                          |                  |      +------------------+
                          | matrix: which    |
                          | consumer x       |
                          | provider pairs   |
                          | are verified     |
+------------------+      |                  |
|   Deploy step    | ask  | knows versions   |
| can-i-deploy?    |----->| deployed per     |
|                  |<-----| environment      |
|                  |yes/no|                  |
| record deployment|record| marks version as |
| after release    |----->| deployed to env  |
+------------------+      +------------------+

Consumer CI publishes pacts to the Pact Broker, provider CI fetches and verifies them and publishes results back, and the deploy step queries the Broker's matrix with can-i-deploy before a release and records the deployment afterwards.

Writing a consumer test with matchers instead of literal values

A consumer test has six steps, and the order matters. First, declare the provider state the interaction depends on, such as "order 42 exists." Second, define the request your client will send: method, path, query, and headers. Third, describe the response you expect. Fourth, start the Pact mock server, which serves exactly that interaction. Fifth, point your real HTTP client at the mock and call it the way production code does. Sixth, let Pact write the pact file. The fifth step is what makes this a test of your client rather than a hand-written fixture. If your code never sends the request, the test fails and no contract is produced.

The response description is where most contracts break. Use matchers instead of literal values:

  • Type matching says "total is a number" instead of "total is 19.99." The provider can return any number.
  • Regex matching constrains format, such as an ISO date or an ID pattern, without pinning the value.
  • Each-like says "items is an array where every element has this shape, with at least one element." It does not fix the length.

In the generated JSON, each matcher appears under matchingRules next to an example value. Provider verification checks the rule. It does not compare against the example.

Over-specification makes contracts brittle. If you assert exact values, include fields the client never reads, or require exactly three array items, the provider fails verification for changes no consumer would notice. After a few false alarms, teams start skipping failures, and the deployment gate loses its value. Assert only what your client actually depends on.

import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { OrderClient } from '../src/orderClient'; // the real production client

const { like, regex, eachLike } = MatchersV3;

const provider = new PactV3({ consumer: 'checkout-web', provider: 'order-service' });

it('fetches an existing order', async () => {
  provider
    .given('order 42 exists') // 1. provider state
    .uponReceiving('a request for order 42')
    .withRequest({ // 2. the request the client sends
      method: 'GET',
      path: '/orders/42',
      headers: { Accept: 'application/json' },
    })
    .willRespondWith({ // 3. expected response: only fields the client reads
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      body: {
        id: like(42), // type match: any number
        total: like(19.99), // type match: any number, not this exact value
        createdAt: regex(/^\d{4}-\d{2}-\d{2}T/, '2024-05-01T10:00:00Z'), // format only
        items: eachLike({ sku: like('SKU-123'), qty: like(1) }, 1), // every element has this shape, min length 1
      },
    });

  // 4. start the mock server, 5. run the real client against it
  await provider.executeTest(async (mockServer) => {
    const client = new OrderClient(mockServer.url);
    const order = await client.getOrder(42);
    expect(order.items.length).toBeGreaterThan(0);
  });
  // 6. on success, Pact writes the pact file with matchingRules for each matcher
});

A pact-js V3 consumer test that covers all six steps and uses type, regex, and each-like matchers, so provider verification checks shape and format instead of exact values or a fixed array length.

Provider verification and designing provider states

Verification flips the direction of the test. The provider starts a real instance of itself, the Pact verifier fetches the relevant contracts from the broker, and each recorded request is replayed over HTTP. The actual response is compared against the consumer's matchers. No consumer code runs here. The contract file is the consumer.

Before each interaction, the verifier calls a provider state hook with the state name the consumer declared, such as "order 42 exists and is unpaid". A good handler creates exactly that and nothing more. For example, it might stub the order repository to return one unpaid order with id 42, or insert a single row into a database that is created per test and torn down afterward. Downstream services the provider calls, such as payments or inventory, should be stubbed. Otherwise verification also tests their availability.

The failure mode is gradual. Teams seed one large shared database "to make all states pass," and handlers become no-ops. Interactions then depend on each other's leftovers, failures stop being reproducible, and verification slows because every run pays for the full fixture. Treat state names as an API that both sides maintain: keep them few, keep them descriptive, and delete handlers when no contract references them.

Configure the verifier with:

  • Provider version: typically the commit SHA.
  • Branch: the provider branch being built.
  • Consumer version selectors: usually consumers' main branches plus whatever is currently deployed or released, so you verify what actually matters.
  • Publishing results: enable this only in CI, so the broker records which versions are verified compatible.
import { Verifier } from '@pact-foundation/pact';
import { startServer } from '../src/server';
import { InMemoryOrderRepo, StubPaymentsClient } from './stubs';

// Each state gets a fresh repo, so no interaction depends on another's leftovers.
let orders = new InMemoryOrderRepo();
const payments = new StubPaymentsClient(); // the real payments service is never called

it('verifies consumer contracts', async () => {
  // Inject stubs through a getter so state handlers can swap the repo between interactions.
  const server = await startServer({ port: 8081, orders: () => orders, payments });

  try {
    await new Verifier({
      providerBaseUrl: '',
      provider: 'order-service',
      pactBrokerUrl: '',
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      providerVersion: process.env.GIT_COMMIT,          // e.g. the commit SHA
      providerVersionBranch: process.env.GIT_BRANCH,     // the provider branch being built
      consumerVersionSelectors: [
        { mainBranch: true },          // what consumers are about to ship
        { deployedOrReleased: true },  // what is running in an environment right now
      ],
      publishVerificationResult: process.env.CI === 'true', // only CI writes to the broker
      stateHandlers: {
        // Create exactly what the state name promises, nothing more.
        'order 42 exists and is unpaid': async () => {
          orders = new InMemoryOrderRepo();
          orders.add({ id: 42, status: 'UNPAID', totalCents: 1999 });
        },
        'no orders exist': async () => {
          orders = new InMemoryOrderRepo();
        },
      },
    }).verifyProvider();
  } finally {
    await server.close();
  }
});

A pact-js provider verification that resets an in-memory repository for each provider state, stubs the downstream payments client, and configures the provider version, branch, consumer version selectors, and CI-only result publishing.

Wiring can-i-deploy, deployment recording, and webhooks into CI/CD

Verification results only matter if something reads them before a release goes out. That is the job of can-i-deploy. Without it, each pipeline checks only its own side. A consumer can merge a new expectation and ship before any provider has verified it, and a provider can pass verification against main while breaking the consumer version actually running in production. Both builds are green, and the break shows up at runtime.

A typical pipeline for either side has three Pact steps:

  1. Publish (consumer only). After the consumer tests pass, run pact-broker publish with --consumer-app-version set to the Git SHA and --branch set to the current branch. Using the SHA makes every contract traceable to exact code.
  2. Gate. Before deploying, run pact-broker can-i-deploy --pacticipant <name> --version <sha> --to-environment <env>. The Broker checks its matrix for verified results between this version and every integration partner currently recorded in that environment. If any pair is unverified or failed, the command exits non-zero and the deploy stops.
  3. Record. After a successful deploy, run pact-broker record-deployment --pacticipant <name> --version <sha> --environment <env>. Skip this step and the Broker's view of what runs where goes stale, so later gates check against the wrong versions.

To avoid waiting on the provider's next scheduled build, configure a Broker webhook on the contract_requiring_verification_published event. It should trigger a provider CI job that verifies only the changed pact. The consumer team gets a verification result within minutes, and their can-i-deploy can pass as soon as that result arrives.

#!/usr/bin/env bash
# Pact steps for a consumer pipeline. A provider pipeline uses the same
# gate and record steps with its own pacticipant name. It skips publish.
set -euo pipefail

# The pact-broker CLI reads the Broker URL and token from these variables.
export PACT_BROKER_BASE_URL=""
export PACT_BROKER_TOKEN="${PACT_BROKER_TOKEN:?set in CI secrets}"

PACTICIPANT="order-web"
VERSION="$(git rev-parse HEAD)"            # Git SHA ties each contract to exact code
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
ENVIRONMENT="production"

# 1. Publish (consumer only). Runs after the consumer Pact tests pass.
#    A Broker webhook on contract_requiring_verification_published then
#    starts provider verification of this pact.
pact-broker publish ./pacts \
  --consumer-app-version "$VERSION" \
  --branch "$BRANCH"

# 2. Gate. Ask whether this version is verified against every partner
#    recorded in the target environment. A non-zero exit stops the deploy.
#    While the webhook-triggered verification is still running, the result
#    is unknown. The command polls instead of failing straight away.
pact-broker can-i-deploy \
  --pacticipant "$PACTICIPANT" \
  --version "$VERSION" \
  --to-environment "$ENVIRONMENT" \
  --retry-while-unknown 12 \
  --retry-interval 10

./deploy.sh "$ENVIRONMENT" "$VERSION"      # your real deploy step

# 3. Record. Tell the Broker what now runs in this environment so later
#    can-i-deploy checks compare against the right versions.
pact-broker record-deployment \
  --pacticipant "$PACTICIPANT" \
  --version "$VERSION" \
  --environment "$ENVIRONMENT"

A CI script that publishes the consumer pact under its Git SHA, waits at the can-i-deploy gate for the webhook-triggered provider verification, and records the deployment so the Broker knows what runs in production.

Evolving an API safely with contracts

Because each consumer's pact records only the fields that consumer actually reads, the provider can add response fields freely. No existing interaction asserts on them, so verification keeps passing. Breaking changes are the ones that invalidate something a pact already records. They need an expand-and-contract sequence, and the Broker serves as the source of truth for when the "contract" step is safe.

To remove or rename a field, publish the new field alongside the old one and migrate each consumer to it. Then use the Broker's matrix to confirm that no consumer version currently recorded as deployed or released in any environment still has a verified pact that references the old field. Only then delete it. Checking just the latest consumer pact is not enough, because older versions may still be running in production.

Pending pacts make the reverse direction safe. A pact stays "pending" until some provider version verifies it successfully. While it is pending, a verification failure is reported to the Broker but does not fail the provider's build. Work-in-progress pacts extend this to pacts from feature branches that the provider's selectors would not otherwise fetch. Together they let a consumer publish a new expectation and get feedback without blocking unrelated provider work.

Change types

Change Safe? Required steps
Add response field Yes Deploy; no coordination
Add optional request param Yes Provider must accept requests without it
Rename field No Add new, migrate consumers, verify matrix, remove old
Remove field No Confirm no deployed consumer version depends on it
Change field type No Treat as a new field, then deprecate the old one
Tighten validation No Check recorded request examples in all deployed pacts first

When to use Pact, when to avoid it, and its impact on cost and engineering time

Pact pays off when you own internal HTTP or message-based services and can name every consumer. Those consumers can publish pacts, and can-i-deploy can then answer a real question about the deployed versions you know about. Public APIs are different. Unknown consumers will never publish pacts, so a provider-driven schema such as OpenAPI or AsyncAPI, plus an explicit versioning and deprecation policy, fits better.

What it does not cover

A pact checks that request and response shapes match. It does not test business logic, multi-hop flows, performance, real authentication configuration, or whether a value is semantically correct. Keep provider functional tests and a thin set of end-to-end smoke tests for those. Contracts also have blind spots. A code path no consumer test exercises is not protected, and a consumer that never adopts Pact is invisible to can-i-deploy.

Costs

You either run a Broker yourself, which means hosting, securing, and backing it up because it gates deploys, or you depend on a hosted one. Provider states need ongoing maintenance as data models change. Teams also need written ownership agreements, or pacts go stale and stop reflecting real usage.

Time savings

The gain comes from replacing slow integration runs in shared environments with fast checks that catch breaking changes before merge.

Illustrative estimate (assumed numbers, not a benchmark): Hours saved per month = (runs avoided × minutes per run ÷ 60) + (breakages caught early × hours per breakage) − Pact upkeep hours

Assume 40 runs avoided at 30 minutes each, 3 breakages at 4 hours each, and 10 hours of upkeep:

(40 × 30 ÷ 60) + (3 × 4) − 10 = 20 + 12 − 10 = 22 hours per month

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 .env by 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.