What a consumer-driven contract is, and what it is not

This article's argument is simple: writing Pact tests is the smaller part of the job. The value comes from wiring contracts into CI so they decide whether a service can ship.

A consumer-driven contract records the interactions one consumer actually depends on: the requests it sends and the parts of each response it reads. If an order service calls GET /customers/{id} and uses only id and email, the contract covers that request and those two fields. It says nothing about the forty other fields the endpoint returns or the endpoints the consumer never calls.

That narrowness is the point. A contract is not a full API schema. An OpenAPI document describes everything a provider offers, while a contract describes what a specific consumer needs. The provider can remove an unused field without breaking anyone, and the contract will confirm that.

A contract is also not a functional test of the provider. It checks that a response has the expected shape and status for a given request in a given state. It does not check that the provider calculated the right discount or saved the right row. That still belongs in the provider's own tests.

Teams adopt contracts to catch interface breakage between independently deployed services without standing up a shared end-to-end environment for every change. The payoff is largest for backend and platform engineers running many HTTP services owned by different teams. In that setting no single person knows every caller, and a full integration environment is slow, flaky, and contested.

How the Pact workflow fits together in CI

The workflow has five moving parts, and each one runs in a specific pipeline.

The consumer test produces the contract. The consumer's unit tests run against a Pact mock server that records each request the consumer makes and the response it expects. When the tests pass, Pact writes those interactions to a pact file.

Consumer CI publishes it. The pipeline uploads the pact file to the Pact Broker, using the git commit SHA as the consumer version and attaching the branch name. The branch lets providers ask for specific pacts, such as those from the consumer's main branch or from versions currently deployed.

The provider verifies it. Provider CI fetches the relevant pacts, starts a real instance of the provider, and replays each recorded request against it. It then compares the actual responses to the expectations and publishes pass or fail results to the Broker, tagged with the provider's own commit SHA.

Webhooks close the gap. When a consumer publishes a changed contract, a Broker webhook triggers a provider verification build. Breakage surfaces within minutes instead of waiting for the provider's next unrelated commit.

Deploys are gated. Each pipeline records deployments and releases to named environments. Before deploying, it runs can-i-deploy, which queries the Broker's matrix of consumer versions, provider versions, and verification results.

That matrix is only reliable if versions are trustworthy. A commit SHA is unique and immutable, and it maps to exactly the code that was built. A semantic version like 2.3.0 can be reused across rebuilds, or left unchanged while the code changes. When that happens, a verification result gets attached to code it never tested.

     CONSUMER CI                 PACT BROKER                 PROVIDER CI
+--------------------+     +----------------------+     +--------------------+
| 1. Unit tests run  |     | Pacts keyed by       |fetch| 3. Fetch relevant  |
|    against Pact    |     | consumer SHA + branch|---->|    pacts           |
|    mock server     |     |                      |     |         |          |
|         |          |     | Verification results |     |         v          |
|         v          |     | keyed by provider SHA|     | Start real provider|
|     pact file      |     |                      |     | Replay requests,   |
|         |          |     | 4. Webhook: changed  | hook| compare responses  |
|         |          |     |    pact -> run verify|---->|         |          |
|         v          | pub |                      |     |         v          |
| 2. Publish pact    |---->|                      |<----| Publish results    |
|    version = SHA   |     | Environments:        |     |    version = SHA   |
|    + branch        |     | recorded deployments |     |                    |
|                    |     | and releases         |     |                    |
| 5. Record deploy   |---->| Matrix: consumer SHA |<----| 5. Record deploy   |
| 6. can-i-deploy?   |<--->| x provider SHA x     |<--->| 6. can-i-deploy?   |
|         |          |     | verification result  |     |         |          |
|         v          |     |                      |     |         v          |
|    DEPLOY if yes   |     |                      |     |    DEPLOY if yes   |
+--------------------+     +----------------------+     +--------------------+

Consumer CI publishes pacts versioned by commit SHA and branch, a Broker webhook triggers provider verification, both pipelines record deployments and releases per environment, and each runs can-i-deploy against the Broker's matrix before deploying.

Writing the consumer test and publishing the pact

A consumer test runs your real client code against the Pact mock server instead of the provider. For each interaction, you declare the request your client will send (method, path, query, relevant headers, body) and the response it expects. When the test passes, Pact writes the pact file, a JSON record of those interactions. Treat that file as a build artifact. It describes what this commit of the consumer depends on, so it should be produced and published only from that commit's pipeline.

The publish step runs after the consumer tests pass. It pushes the pact to the broker with the commit SHA as the consumer version and the current branch name as metadata. The SHA ties the contract to exact code, for the reasons covered above. The branch tells the broker which line of development the pact belongs to, which later lets providers verify against the main-branch pacts rather than every feature branch.

Matcher choice decides whether the contract stays useful. Asserting literal values, such as a specific ID or timestamp, makes provider verification fail on harmless data changes. Teams then learn to ignore red Pact builds. Matching everything loosely has the opposite problem: verification passes while the consumer still breaks.

A practical rule:

  • Match by type for IDs, names, amounts, and timestamps.
  • Match by regex for formatted strings such as dates and UUIDs.
  • Pin exactly the values your code branches on: status codes, enum values, discriminator fields, and content types.
  • Omit fields your client never reads. Including them couples you to data you don't use.
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { getOrder } from '../src/orderClient'; // real client code under test

const { uuid, decimal, string, regex } = MatchersV3;
const ORDER_ID = '3f6c2a9e-8b1d-4c7a-9e2f-5a1b6c7d8e9f';

// Pact file is written to ./pacts and treated as a build artifact
const provider = new PactV3({ consumer: 'checkout-web', provider: 'order-service', dir: './pacts' });

it('fetches an order', () => {
  provider
    .given('an order exists')
    .uponReceiving('a request for a single order')
    .withRequest({ method: 'GET', path: `/orders/${ORDER_ID}`, headers: { Accept: 'application/json' } })
    .willRespondWith({
      status: 200, // pinned: the client branches on it
      headers: { 'Content-Type': 'application/json' }, // pinned: the client parses JSON
      body: {
        id: uuid(ORDER_ID), // format matters, not the specific value
        status: 'SHIPPED', // pinned enum: the client branches on it
        total: decimal(19.99), // type only
        customerName: string('Ada Lovelace'), // type only
        placedAt: regex('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$', '2026-01-15T10:00:00Z'),
        // fields the client never reads are deliberately omitted
      },
    });

  return provider.executeTest(async (mockServer) => {
    const order = await getOrder(mockServer.url, ORDER_ID);
    expect(order.status).toBe('SHIPPED');
  });
});

// CI, after tests pass: publish with the commit SHA as version plus branch metadata
// pact-broker publish ./pacts \
//   --consumer-app-version "$GIT_COMMIT_SHA" --branch "$GIT_BRANCH" \
//   --broker-base-url "$PACT_BROKER_BASE_URL" --broker-token "$PACT_BROKER_TOKEN"

A PactV3 consumer test that pins status, content type and enum values, matches other fields by type or regex, omits unread fields, and then publishes the pact with the commit SHA and branch.

Provider verification and provider states

The provider's CI job does not read pact files from the consumer's repository. It asks the broker for the pacts that matter, usually those from the consumers' main branches and the versions currently deployed to each environment. It then starts the provider locally and replays every recorded request against it. The verifier compares each real response with the consumer's expectations. Results are published back to the broker, tagged with the provider's version and branch. Publish only from CI. If developers publish from their laptops, the broker's verification matrix fills with results nobody can reproduce.

Each interaction declares a provider state, such as "an order 42 exists and is unpaid" or "the customer has no saved cards". Write states as business preconditions, not instructions like "insert row into orders". The wording is shared between teams and should not change when the schema does. Before the matching request runs, a state handler makes that condition true. It does this by seeding data through the provider's own repositories, or by stubbing a downstream client so the payment service returns "declined".

State setup tends to grow until it duplicates your test fixtures. A few rules keep it small:

  • Seed through domain code. Use the provider's services or factories rather than raw SQL. Migrations then cannot silently break the setup.
  • Stub at the provider's outbound boundary. Replace the provider's own clients for downstream services instead of starting those services.
  • Create only what the interaction reads. Do not load a realistic dataset.
  • Reset state between interactions so their order does not matter.
  • Push back on new states. If consumers keep asking for new ones, review their pacts. They may be pinning behaviour they never use.
import { Verifier } from '@pact-foundation/pact';
import { startServer, resetDatabase } from '../src/test-support';
import { orderFactory } from '../src/orders/factory';
import { paymentClient } from '../src/payments/client';

it('honours consumer pacts', async () => {
  const server = await startServer({ port: 8081 }); // real provider, local instance

  await new Verifier({
    provider: 'order-service',
    providerBaseUrl: '',
    pactBrokerUrl: '',
    pactBrokerToken: process.env.PACT_BROKER_TOKEN,
    // Verify consumers' main branches and whatever is deployed or released
    consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
    // Only CI publishes, so the broker matrix stays reproducible
    publishVerificationResult: process.env.CI === 'true',
    providerVersion: process.env.GIT_COMMIT,
    providerVersionBranch: process.env.GIT_BRANCH,
    // Runs before every interaction, so interaction order never matters
    beforeEach: async () => { await resetDatabase(); },
    stateHandlers: {
      // Business precondition; seeded through domain code, not raw SQL
      'an order 42 exists and is unpaid': async () => {
        await orderFactory.create({ id: 42, status: 'UNPAID' });
      },
      // Stub the provider's own outbound client instead of starting payments
      'the payment provider declines the card': async () => {
        paymentClient.charge = async () => ({ outcome: 'declined' });
      },
    },
  }).verifyProvider();

  await server.close();
});

A pact-js provider verification that fetches pacts from the broker, verifies a local instance, publishes results only from CI, and uses state handlers that seed via domain factories or stub an outbound client.

Recording deployments and gating releases with can-i-deploy

The broker can only answer "is this safe?" if it knows what is running where. After every successful deploy, both pipelines run pact-broker record-deployment --pacticipant <name> --version <sha> --environment <env>. For artifacts that ship as versions customers install, such as mobile apps or libraries, use record-release, because several versions can be live at once.

Before every deploy step, in the consumer and the provider pipeline alike, run pact-broker can-i-deploy --pacticipant <name> --version <sha> --to-environment <env>. It checks the candidate against the versions recorded in that environment and exits non-zero if any required verification is missing or failed. Put it in front of each environment, not only production.

The gate is only as good as its bookkeeping. If a pipeline skips record-deployment, for example on a manual hotfix or a rollback, the broker keeps checking against a version that is no longer running. Reusing a version number, such as 1.2.0 across rebuilds, attaches verification results to the wrong code. In both cases can-i-deploy returns a confident wrong answer, which is worse than having no gate because teams stop looking. Use the git SHA as the version, and route every deploy path, including rollbacks, through the same recording step.

Treat the broker as production infrastructure: back it up, monitor it, and give it an owner. Decide your outage policy in advance. The usual choice is to fail closed. Also define an emergency override: a pipeline flag that skips can-i-deploy only when a named approver sets it and states a reason. The override should still record the deployment, and the bypass should be logged and reviewed afterwards.

#!/usr/bin/env bash
# deploy.sh <environment> - every deploy path, including rollbacks and hotfixes, runs this script.
# Requires PACT_BROKER_BASE_URL and PACT_BROKER_TOKEN in the CI environment.
set -euo pipefail

PACTICIPANT="order-service"
VERSION="$(git rev-parse HEAD)"   # git SHA, never a reused number like 1.2.0
ENVIRONMENT="$1"                  # e.g. test, staging, production

if [[ -n "${EMERGENCY_OVERRIDE_APPROVER:-}" ]]; then
  # Audited override: both a named approver and a reason are mandatory.
  : "${EMERGENCY_OVERRIDE_REASON:?override requires EMERGENCY_OVERRIDE_REASON}"
  echo "PACT GATE BYPASSED env=${ENVIRONMENT} version=${VERSION}" \
       "approver=${EMERGENCY_OVERRIDE_APPROVER} reason=${EMERGENCY_OVERRIDE_REASON}" >&2
  # Placeholder: also send this line to wherever your team reviews bypasses.
else
  # Fail closed: a non-zero exit (failed or missing verification, or an
  # unreachable broker) stops the script before anything is deployed.
  pact-broker can-i-deploy \
    --pacticipant "$PACTICIPANT" \
    --version "$VERSION" \
    --to-environment "$ENVIRONMENT" \
    --retry-while-unknown 6 \
    --retry-interval 10
fi

./scripts/rollout.sh "$ENVIRONMENT" "$VERSION"   # your actual deploy step

# Always record, even after an override, so the broker knows what is running.
# If the broker is down here, the job fails loudly: rerun this step once it recovers.
pact-broker record-deployment \
  --pacticipant "$PACTICIPANT" \
  --version "$VERSION" \
  --environment "$ENVIRONMENT"

A shared deploy script for consumer and provider pipelines. It gates each environment with can-i-deploy and fails closed when the broker is unreachable. It allows an emergency bypass only with a named approver and a logged reason, and it always records the deployment against the git SHA.

Handling new features before the provider supports them

When a consumer needs an endpoint or field that does not exist yet, its new pact will fail provider verification by design. Without help, the consumer team must either wait or break the provider's build. Pact offers two mechanisms to avoid that.

Pending pacts apply to any contract content the provider has never verified successfully. Verification still runs and reports failures, but those failures do not fail the provider build. A new expectation therefore cannot block unrelated provider work. Once the provider verifies the pact successfully on its main branch, the pact is no longer pending, and future regressions fail the build as normal.

Work-in-progress pacts let the provider pick up pacts published from consumer feature branches that its verification configuration does not explicitly select. The provider team sees the incoming expectations in its own CI before anyone asks, and can build against them.

The order that keeps can-i-deploy green on both sides:

  1. The consumer publishes the pact from a feature branch and does not merge.
  2. The provider implements the feature, verifies the pact, merges, and deploys.
  3. The consumer reruns can-i-deploy, which now passes, then merges and deploys.

Deploying the provider first works because adding fields or endpoints is backward compatible for existing consumers.

Ownership

Consumer-driven contracts give consumers the first draft of the API. Left unchecked, provider teams end up implementing interfaces they never agreed to. Treat a new pact interaction like an API proposal. Open it as a reviewable change and require a provider reviewer before the consumer merges. Settle naming, error shapes, and pagination in that review, not after verification fails.

When to use Pact, when to avoid it, and the alternatives

Pact earns its keep when you control both sides of an HTTP interaction and can name every consumer. Each consumer states exactly which fields and status codes it relies on, so the provider learns what it can safely change. That is the core value of consumer-driven contracts.

The fit weakens with asynchronous messaging. Message pacts exist, but they verify the payload a provider produces, not the broker, topic routing, ordering, or retry behaviour. Teams usually find them less ergonomic than HTTP pacts. For public APIs with unknown consumers the model breaks down entirely, because nobody can publish a pact on behalf of clients you have never met. A provider-published schema, versioned and checked for breaking changes, suits that case better.

Contracts verify shape and interaction surface, not business correctness. A provider can pass verification while returning the wrong price, because the pact only asserted that price is a number.

Approach Catches Misses Best for
Pact (consumer-driven) Breaking changes to fields a consumer uses Wrong values, infrastructure faults Internal HTTP pairs
Provider-driven schema Schema drift against a published spec What any given consumer actually uses Public or many-consumer APIs
Integration tests (real dependencies) Database, auth, and serialisation faults Cross-team compatibility Each service's own edges
End-to-end suite Broken user journeys Precise cause of the failure A few critical flows

Keep integration tests for each service's own dependencies. Keep a small end-to-end suite for revenue-critical journeys and for configuration such as gateways and network policy. Then remove the end-to-end cases that existed only to catch field renames and missing endpoints, since Pact now covers those.

Rollout plan and impact on cost and engineering time

Staged rollout

Start with one high-traffic consumer/provider pair whose interface changes often. That pair gives you the most signal in the shortest time. Wire up three things for it. The consumer publishes its pact on every build. The provider verifies on every build. A broker webhook triggers provider verification whenever a pact changes. Record deployments from the first day, because can-i-deploy can only answer correctly if it knows what is actually running in each environment.

Stage can-i-deploy mode Move on when
Pilot pair Reports a result, does not block the deploy Its results match what really broke for several weeks
Enforce pilot Blocks the deploy You have seen no false failures from stale provider states
Expand Blocks for every covered pair Most of the interfaces that caused past incidents are covered

Costs and payoff

Running pact checks has four ongoing costs:

  • CI time: extra minutes for provider verification jobs.
  • Cross-team effort: consumers write the tests, while providers own the provider states.
  • Broker operation: hosting, upgrades and backups.
  • Provider-state maintenance: states must be updated as data models change. This is the cost that keeps growing.

The payoff is late. While only one pair is covered, you still need the full end-to-end suite for every other interface. The savings appear once enough pairs are covered to shrink that suite. At that point you get fewer broken deploys, fewer flaky reruns, and interface feedback within minutes instead of after a staging run.

Illustrative estimate (assumed numbers, not a benchmark)

Net hours per month = (broken deploys avoided × hours per incident) + (flaky reruns avoided × hours per rerun) − maintenance hours

Assumed inputs:

  • 3 broken deploys avoided per month, at 4 hours each
  • 20 flaky reruns avoided per month, at 0.5 hours each
  • 6 hours per month of maintenance across four covered pairs

Result: (3 × 4) + (20 × 0.5) − 6 = 16 hours saved 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.
  • 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.