What consumer-driven contracts are and how they differ from OpenAPI

In pact contract testing, the consumer defines the contract. A consumer test runs against a Pact mock provider. The test sends the requests it really sends and asserts on the response fields it really reads. Pact records those interactions in a contract file, the pact. Later, that file is replayed against the real provider. If the consumer only reads id and status from an order response, the contract mentions only id and status. The other forty fields the provider returns are not part of it.

That is the core difference from a provider-published schema such as OpenAPI. An OpenAPI document describes everything the provider offers. It cannot tell you which of those things any consumer depends on. A pact describes the subset a specific consumer relies on, so the two answer different questions.

OpenAPI (provider-published) Consumer-driven contract (Pact)
Written by Provider team Generated from consumer tests
Describes Everything the API offers What one consumer actually uses
Detects Provider drifting from its own spec Provider breaking a real consumer
Safe to remove an unused field? Unknown Yes, if no pact references it

For independently deployed services, the last row decides the question. A provider wants to rename or drop a field and needs to know whether any consumer version currently in production would break. A schema diff flags every change as potentially breaking. Contracts flag only the changes that break someone.

This article takes that as its angle. The value of Pact comes from using contracts to answer "can I deploy this version?" at release time. Treating Pact as a second test suite that sits beside your integration tests captures little of that value.

How the Pact workflow fits together: consumer, provider, and broker

The loop has four steps, and each one produces data that the next step depends on.

1. The consumer generates a contract

The consumer's unit tests run against a Pact mock server instead of the real provider. Each interaction the test exercises, such as a request and the minimal response the consumer relies on, is written to a pact file. Because the file comes from code that actually runs, the contract describes what the consumer uses. It does not describe what someone believes the consumer uses.

2. The contract is published to the broker

CI publishes the pact file to a Pact Broker and tags it with the consumer's version, which is usually the git commit SHA. This is the key design choice. Contracts are attached to specific builds, not to "the latest." That lets the broker reason about exactly which code is running where.

3. The provider verifies and reports back

The provider's pipeline asks the broker which contracts it needs to satisfy. Typically these are the ones from consumer versions on the main branch or currently deployed. It replays each request against a running instance of itself and checks the responses. It then publishes pass/fail results, keyed to its own commit SHA.

4. Deployments are recorded and queried

When either side deploys, it records the version and environment with the broker. Before a release, can-i-deploy checks the resulting matrix. The question it answers is: has this version been verified against every version currently in the target environment?

The broker is shared infrastructure, and both teams' pipelines block on it. It needs the same ownership, uptime, and access control as your artifact registry.

  CONSUMER CI                                PROVIDER CI
+------------------------+                 +------------------------+
| 1. Unit tests vs Pact  |                 | 3. Replay each pact vs |
|    mock -> pact file   |                 |    running provider    |
+-----------+------------+                 +----+------------^------+
            |                                   |            |
            | 2. publish pact                   | results    | fetch
            |    (consumer SHA)                 | (provider  | pacts
            |                                   |  SHA)      |
            v                                   v            |
+-------------------------------------------------------------------+
|                PACT BROKER (shared infrastructure)                |
| pacts by consumer SHA | results by provider SHA | deployed by env |
|                    => compatibility matrix <=                     |
+-------------------------------------------------------------------+
            ^                     |                     ^
            | can-i-deploy?       | yes / no            | record deployment
            |  (SHA, env)         |                     |  (SHA, env)
            |                     v                     |
+-----------+---------------------------------------------+---------+
| 4. Release pipeline (consumer or provider):                       |
|    gate on can-i-deploy -> deploy -> record version + environment |
+-------------------------------------------------------------------+

The Pact loop: the consumer publishes SHA-tagged contracts, the provider fetches and verifies them and reports results, and each release queries can-i-deploy against the broker's compatibility matrix and records its deployment.

Writing a consumer test that checks shape, not test data

A consumer test should call your real API client, not a hand-rolled HTTP request. Point the client's base URL at the Pact mock server, register the expected interaction, invoke the client method, and assert on what it returns. If the test bypasses your client, the contract describes a request your code never makes.

In the response definition, include only the fields the consumer reads, and describe them with matchers rather than literals. This is Postel's law applied to contracts: require exactly as much as the consumer can tolerate.

Take an order client that reads id, status, and total:

  • Good: id matched by type, status matched by a regex covering the values the client handles, and total matched as a number. Nothing else appears in the contract.
  • Over-specified: id must equal "ord-42", total must equal 19.99, and createdAt, customer, and lineItems are all listed even though the client ignores them.

The over-specified version fails whenever the provider's fixture data changes or an unused field is restructured. Neither change can break the consumer. After a few of these false alarms, the provider team learns to treat Pact failures as noise, and the gate stops protecting anyone.

Business rules such as "a cancelled order has a zero total" belong in the provider's own functional tests. Contracts check shape and compatibility.

For message consumers, there is no HTTP mock. Pact gives the test a message body built from your matchers, and the test passes that body directly to your handler. The handler must process it without error.

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

const { like, regex, number } = MatchersV3;

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

describe('OrderClient', () => {
  it('fetches an order by id', () => {
    provider
      .given('an order exists')
      .uponReceiving('a request for an order')
      .withRequest({ method: 'GET', path: regex('/orders/[a-z0-9-]+', '/orders/ord-42') })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        // Only the three fields this client reads. Matchers check type and format,
        // so the provider can use any fixture values and add unrelated fields.
        body: {
          id: like('ord-42'),
          status: regex('pending|paid|shipped|cancelled', 'paid'),
          total: number(19.99),
        },
      });

    return provider.executeTest(async (mockServer) => {
      // Point the real client at the Pact mock instead of hand-writing the HTTP call.
      const client = new OrderClient(mockServer.url);
      const order = await client.getOrder('ord-42');

      // Assert on what the client returns, not on business rules.
      expect(order.status).toBe('paid');
      expect(typeof order.total).toBe('number');
    });
  });
});

// Over-specified (avoid): body: { id: 'ord-42', total: 19.99, createdAt: '...', customer: {...}, lineItems: [...] }
// Literal values and unread fields fail on fixture changes that cannot break this consumer.

A Pact JS consumer test that drives the real OrderClient against the Pact mock server and constrains only the fields the client reads, using type and regex matchers instead of literal values.

Provider verification and provider states

Provider verification runs in the provider's own pipeline. The verifier fetches every pact that names this provider, starts a real instance of the service, and replays each recorded request against it. It then checks the actual response against the consumer's matchers. Passing means the provider returns what consumers rely on, field by field.

Most interactions depend on data. A consumer that expects GET /users/42 to return a user declares a provider state such as "user 42 exists". Before replaying that interaction, the verifier calls a state handler you register on the provider side. A typical handler for that state truncates the users table and inserts a row with ID 42. It writes to the database or repository directly instead of calling the account service that normally creates users. Downstream calls make verification slow, add dependencies, and cause failures that have nothing to do with the contract. Stub external clients for the same reason.

The verification configuration is short:

  • the provider name
  • the broker URL and credentials
  • consumer version selectors, usually the main branch plus deployed and released versions
  • the provider version, set to the git SHA, along with its branch
  • a flag that publishes results only from CI

State handlers are ongoing maintenance. When the provider's schema changes, every handler that seeds the affected tables must change with it. Each handler must also reset what it touches. If it doesn't, rows left over from an earlier interaction make results depend on execution order, and verification becomes flaky.

For message contracts, the producer maps each message description to a function. That function builds the payload with the real serialization code, and the verifier checks the result against the consumer's matchers.

import { Verifier } from '@pact-foundation/pact';
import { db } from './test/db'; // provider's own data-access layer (placeholder)
import { startServer } from './src/server'; // starts the real provider app (placeholder)

async function verify() {
  const server = await startServer({ port: 8081 });

  try {
    await new Verifier({
      provider: 'user-service',
      providerBaseUrl: '',
      pactBrokerUrl: '',
      pactBrokerToken: process.env.PACT_BROKER_TOKEN,
      // Verify against main plus whatever is deployed or released
      consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
      providerVersion: process.env.GIT_SHA,
      providerVersionBranch: process.env.GIT_BRANCH,
      // Only CI publishes results; local runs stay private
      publishVerificationResult: process.env.CI === 'true',
      stateHandlers: {
        // Seed the table directly instead of calling the account service.
        // Truncate first so leftovers from earlier interactions cannot leak in.
        'user 42 exists': async () => {
          await db.query('TRUNCATE TABLE users');
          await db.query(
            'INSERT INTO users (id, name, email) VALUES ($1, $2, $3)',
            [42, 'Ada Example', 'ada@example.test'],
          );
        },
        'no users exist': async () => {
          await db.query('TRUNCATE TABLE users');
        },
      },
    }).verifyProvider();
  } finally {
    await server.close();
  }
}

verify().catch(() => process.exit(1));

A Pact JS provider verification that fetches pacts from the broker, replays them against a running instance, and uses state handlers that reset and seed the users table directly.

Wiring Pact into CI with can-i-deploy as the release gate

Each side of the contract gets its own pipeline, and the broker connects them.

Consumer pipeline

After the consumer tests pass, publish the generated pact files with pact-broker publish. Tag the upload with the consumer's version, which should be the git SHA, and with its branch. Versions tied to commits keep the broker's records traceable. Arbitrary build numbers break that link.

Provider pipeline

The provider should not verify every pact ever published. Most of those consumer versions are dead. Use consumer version selectors to fetch only the ones that matter:

  • the consumer's main branch
  • any version currently deployed or released to an environment

This keeps verification fast. It also avoids failures caused by consumers that no longer run anywhere.

Webhooks

Configure a broker webhook on the "contract requiring verification published" event. It triggers a provider build that verifies the new pact. A consumer change then gets a verdict within minutes, without waiting for the provider's next commit.

The release gate

Before deploying version V of any participant, run:

pact-broker can-i-deploy --pacticipant <name> --version V --to-environment production

The broker checks V against the versions it has recorded as deployed in production. If a verified pact is missing, or a verification failed, the command exits non-zero and the deploy stops.

After the deploy succeeds, run pact-broker record-deployment with the same name, version, and environment. Timing matters here:

  • Recording skipped: the broker keeps checking new releases against a stale version.
  • Recording done before the rollout finishes: the broker records a version that may never have gone live.

Either way, the compatibility matrix no longer matches reality, and the gate approves deploys it should block.

Running the broker

The broker sits on every release path, so treat it as production infrastructure. Give it a named owner, put it behind authentication, back up its database, and monitor its availability. If the broker is down, nobody can deploy.

#!/usr/bin/env bash
set -euo pipefail

# The pact-broker CLI reads these; inject the token from CI secrets, never hardcode it.
export PACT_BROKER_BASE_URL=""
export PACT_BROKER_TOKEN="${PACT_BROKER_TOKEN:?set in CI secrets}"

PACTICIPANT="checkout-web"
VERSION="$(git rev-parse HEAD)"   # git SHA keeps broker records traceable to commits
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
ENVIRONMENT="production"

# --- Consumer pipeline: publish pacts after consumer tests pass ---
pact-broker publish ./pacts \
  --consumer-app-version "$VERSION" \
  --branch "$BRANCH"

# --- Release gate: check V against what is recorded as deployed in the target env ---
# Retries give the webhook-triggered provider verification time to report back.
pact-broker can-i-deploy \
  --pacticipant "$PACTICIPANT" \
  --version "$VERSION" \
  --to-environment "$ENVIRONMENT" \
  --retry-while-unknown 30 \
  --retry-interval 10
# Non-zero exit above stops the script (set -e), so nothing below runs.

# Placeholder for your real deploy; it must block until the rollout has completed.
./deploy.sh "$ENVIRONMENT" "$VERSION"

# Record only after the rollout succeeded. Skipping this leaves the broker checking
# against a stale version; running it before deploy.sh finishes records a version
# that may never have gone live.
pact-broker record-deployment \
  --pacticipant "$PACTICIPANT" \
  --version "$VERSION" \
  --environment "$ENVIRONMENT"

A CI script that publishes pacts tagged with the git SHA and branch, gates the deploy with can-i-deploy against production, and records the deployment only after the rollout succeeds.

Walkthrough: catching a renamed response field

Suppose the orders service renames customerName to customer.displayName in its order response. The change compiles, unit tests pass, and the provider's own schema is internally consistent. Provider verification then replays the pacts from the broker and fails. The failure names the consumer (say, billing-ui), the interaction description ("a request for order 42 given the order exists"), and the expectation that broke: a missing customerName key at the path the consumer declared. The provider author now knows who depends on the field and what that consumer expects to receive.

The expand/contract sequence

  1. Expand. Add customer.displayName and keep returning customerName. Verification passes because the provider now produces a superset of what consumers expect.
  2. Migrate. Each consumer switches to the new field and publishes a new pact. Once that consumer version is deployed and recorded in the broker, its contract no longer mentions customerName.
  3. Confirm. Look at the pacts for the consumer versions recorded as deployed in each environment. Then run can-i-deploy for a provider build that omits the old field. If any deployed consumer still expects it, the gate fails.
  4. Contract. Remove customerName and release.

Where the time goes

Illustrative estimate. Assume a shared staging environment with a half-day deploy cycle, and assume that diagnosing a cross-team break there takes two engineers about three hours, including tracing the fault and negotiating a rollback. Catching the same break in the provider's CI takes one engineer a few minutes to read the failure. The larger saving is that no other team's work in staging gets blocked while the break is being found.

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

Pact pays off when several teams own services that deploy on independent schedules. That is where a shared staging environment becomes the bottleneck, and where consumer-driven contracts give each team a fast, local answer about API compatibility without waiting on anyone else.

Skip it for a small system owned by one team or kept in one repo, or for services that always ship together. In those cases, a compiler or an ordinary integration test already sees both sides of the call. Public APIs with unknown third-party clients are also a poor fit, because those clients will never publish contracts. They still need a provider-side schema and an explicit versioning policy.

Approach Catches Misses
OpenAPI schema validation Responses that drift from the published spec Which fields consumers actually rely on
Pact contract tests Changes that break a known consumer's expectations, before deploy Anything beyond request and response shape
Integration tests Real wiring to one dependency, such as serialisation and config Independent changes by other teams
End-to-end tests Cross-service flows, auth, and networking Fast, precise failure attribution

Contracts check shape. They do not catch semantic changes that keep the same shape, such as an amount switching from cents to dollars. They also miss performance regressions, auth or network misconfiguration, and emergent multi-service behaviour. Keep a thin smoke or end-to-end layer for those.

Budget for the running costs:

  • Broker ownership: someone has to own the Pact Broker.
  • Provider states: they need upkeep as data models change.
  • Coordination: teams must agree on naming conventions and on who fixes a failing verification.
  • Partial coverage: early value is uneven. A green can-i-deploy covering three of ten consumers says nothing about the other seven.

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, 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.