Skip to main content

Bi-Directional Contract Testing Explained

Classic consumer-driven contracts with Pact break down the moment the provider is out of your control. A third-party payment gateway, a legacy monolith maintained by another department, or a public API that publishes an OpenAPI spec but has no interest in running your test harness — these are all cases where the CDC model hits a wall. This guide is part of the Contract Testing for Microservices Architectures cluster and covers how bi-directional contract testing (BDCT) solves that friction, how to wire it up end-to-end, and where it falls short compared to classic CDC.

Symptom: CDC Friction With Third-Party and Legacy Providers

The problem surfaces as one of three failure modes:

Mode 1 — Provider refuses to run consumer tests. Your Pact workflow expects the provider team to integrate a Verifier into their CI pipeline, pull your pact files, and run them against a live provider instance. A third-party vendor or a team with a different release cadence and CI stack will not do this. The CDC loop is broken before it starts.

Mode 2 — Provider can’t boot in CI reliably. Legacy services often carry heavyweight state: a proprietary database, a licensed binary, or a startup sequence that takes minutes. Standing them up to replay a handful of Pact interactions is impractical. Flaky provider boots create false failures that erode trust in the contract gate itself.

Mode 3 — The provider already publishes a machine-readable spec. Many SaaS APIs and internal platform teams maintain an OpenAPI document as their source of truth. Running CDC on top of that is redundant; you are re-verifying something the spec already expresses, but with more infrastructure cost and operational coupling.

In each of these cases, CI logs show one of:

Error: Pact verification failed.
Provider verification not found for version abc123.
  No verification results were published by the provider.
  can-i-deploy: FAILED — missing verification for consumer-provider pair.

The provider verification step is simply absent, so can-i-deploy blocks every consumer deploy indefinitely.

Root Cause: CDC Requires Provider Participation

CDC works by replaying recorded consumer expectations against a running provider. The provider must:

  1. Boot in a deterministic state.
  2. Pull and execute the consumer’s Pact file.
  3. Publish a verification result back to the broker.

All three steps require the provider to actively participate in the consumer’s test infrastructure. When the provider is a third party or a legacy system, none of those steps are feasible. The structural coupling is the problem — not a misconfiguration.

How Bi-Directional Contract Testing Works

BDCT decouples verification from provider execution. Instead of booting the provider, it compares two static artefacts:

  • The consumer Pact — a JSON file recording the HTTP interactions the consumer expects (request shape, response shape, matchers). Generated by running the consumer test suite against a Pact mock server.
  • The provider OpenAPI spec — the provider’s own authoritative description of its HTTP surface. Published by the provider team independently of the consumer.

PactFlow holds both artefacts and runs a cross-validation algorithm that checks every interaction in the consumer Pact against the provider’s OpenAPI spec. It verifies that:

  • Every request path and method the consumer sends is declared in the spec.
  • Every response field the consumer expects is present in the spec’s response schema.
  • Every response status code the consumer expects is a declared response code in the spec.
  • Matchers in the Pact (type, regex, like) are compatible with the OpenAPI schema types.

No provider needs to be running. The spec is the verification artefact.

CDC vs Bi-Directional Contract Testing comparison Left side shows classic CDC: consumer generates pact, publishes to broker, provider boots and runs pact, publishes verification result. Right side shows BDCT: consumer generates pact, provider publishes OpenAPI spec, broker cross-validates the two artefacts statically without a running provider.

Classic CDC Bi-Directional (BDCT)

Consumer Broker Provider publish pact pull pact boot provider replay pact publish result can-i-deploy

⚠ Provider must participate in CI

Consumer PactFlow Provider (OpenAPI only) publish pact publish OpenAPI spec cross-validate pact vs spec (static, no boot) can-i-deploy

✓ No provider participation needed

Step 1: Publish the Provider’s OpenAPI Spec to PactFlow

The provider team publishes their OpenAPI document to PactFlow using the Pact CLI. This is the only action required from the provider side — no test harness, no running service.

# In the provider's CI pipeline (e.g. GitHub Actions)
# Pact CLI version: @pact-foundation/pact-broker@12.x

npx pact-broker publish-provider-contract \
  ./openapi/payment-api.yaml \
  --provider payment-api \
  --provider-app-version "$GITHUB_SHA" \
  --branch "$GITHUB_REF_NAME" \
  --content-type application/yaml \
  --verification-success \
  --broker-base-url "$PACTFLOW_URL" \
  --broker-token "$PACTFLOW_TOKEN"

Key flags explained:

  • --provider-app-version — the exact commit SHA that produced this spec. PactFlow needs this to populate the deployment matrix later.
  • --branch — enables mainBranch and deployedOrReleased version selectors when the consumer runs can-i-deploy.
  • --verification-success — marks the provider spec as self-verified. For higher confidence, pass --verification-results ./lint-report.json with a Spectral or Vacuum lint output so PactFlow records the linting status alongside the spec.
  • --content-type application/yaml — PactFlow accepts both JSON and YAML OpenAPI 3.0/3.1.

Run this step on every commit that touches the spec. In practice, gate it behind a file-change filter so trivial non-spec commits skip the upload.

# .github/workflows/publish-provider-spec.yml
name: Publish Provider OpenAPI Spec
on:
  push:
    paths:
      - 'openapi/**'   # only run when the spec changes
jobs:
  publish-spec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install --save-dev @pact-foundation/pact-broker@12
      - name: Lint spec before publishing
        # Spectral 6.11 — catch errors before uploading a broken spec
        run: npx @stoplight/spectral-cli@6 lint ./openapi/payment-api.yaml
      - name: Publish spec to PactFlow
        run: |
          npx pact-broker publish-provider-contract \
            ./openapi/payment-api.yaml \
            --provider payment-api \
            --provider-app-version "$GITHUB_SHA" \
            --branch "$GITHUB_REF_NAME" \
            --content-type application/yaml \
            --verification-success \
            --broker-base-url "$PACTFLOW_URL" \
            --broker-token "$PACTFLOW_TOKEN"
        env:
          PACTFLOW_URL: ${{ secrets.PACTFLOW_URL }}
          PACTFLOW_TOKEN: ${{ secrets.PACTFLOW_TOKEN }}

Why lint before publishing: PactFlow accepts malformed specs and will report a cross-validation failure later, making root cause ambiguous. Linting at publish time surfaces spec errors early in the provider’s own pipeline.

Step 2: Generate and Publish the Consumer’s Pact

The consumer side is identical to classic CDC. Run the consumer test suite, which drives a Pact mock server and records every HTTP interaction into a .pact JSON file.

// consumer.pact.test.ts — @pact-foundation/pact@13
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, string, integer } = MatchersV3;

const provider = new PactV3({
  consumer: 'checkout-service',
  provider: 'payment-api',
  dir: './pacts',
});

it('processes a payment', () => {
  return provider
    .given('a valid payment method exists')
    .uponReceiving('POST /v1/charges')
    .withRequest({
      method: 'POST',
      path: '/v1/charges',
      headers: { 'Content-Type': 'application/json' },
      body: { amount: integer(5000), currency: string('usd') },
    })
    .willRespondWith({
      status: 201,
      headers: { 'Content-Type': 'application/json' },
      body: {
        id: like('ch_1A2B3C'),
        amount: integer(5000),
        status: string('succeeded'),
      },
    })
    .executeTest(async (mock) => {
      const res = await fetch(`${mock.url}/v1/charges`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ amount: 5000, currency: 'usd' }),
      });
      expect(res.status).toBe(201);
    });
});

Publish the generated pact, tagged with version and branch, just as you would for classic CDC:

# In the consumer's CI pipeline
npx pact-broker publish ./pacts \
  --consumer-app-version "$GITHUB_SHA" \
  --branch "$GITHUB_REF_NAME" \
  --broker-base-url "$PACTFLOW_URL" \
  --broker-token "$PACTFLOW_TOKEN"

PactFlow automatically detects that the corresponding provider has a published OpenAPI spec and queues a cross-validation run. No webhook configuration is needed on the consumer side — PactFlow triggers cross-validation as soon as both artefacts exist for a matching consumer-provider name pair.

Step 3: The Cross-Comparison (What PactFlow Does)

PactFlow’s cross-validation algorithm walks every interaction in the Pact and checks it against the OpenAPI spec using an OpenAPI request/response validator internally (based on openapi-request-coercer and openapi-response-validator under the hood). For each interaction it verifies:

Check Pass condition Fail example
Path exists in spec POST /v1/charges declared Consumer sends POST /v1/charge (missing s)
Request body schema Consumer body matches spec’s requestBody schema Consumer sends currency as integer; spec requires string
Response status declared 201 is a declared response code Consumer expects 201; spec only declares 200 and 400
Response body schema Consumer response matchers compatible with spec schema Consumer uses like('ch_1A2B3C') for a field spec marks as format: uuid — passes if UUID is a string subtype
Required fields present All required fields in spec appear in Pact interaction Spec marks status as required; consumer Pact omits it

The result — pass or fail, with a detailed diff — is recorded in PactFlow’s deployment matrix under the provider version that published the spec. No running provider was involved.

Step 4: Gate Deployment with can-i-deploy

The can-i-deploy command is identical to the CDC workflow. PactFlow treats BDCT verification results exactly the same as live provider verification results in the matrix.

# In the consumer's deploy job, before promoting to the target environment
npx pact-broker can-i-deploy \
  --pacticipant checkout-service \
  --version "$GITHUB_SHA" \
  --to-environment production \
  --broker-base-url "$PACTFLOW_URL" \
  --broker-token "$PACTFLOW_TOKEN" \
  --retry-while-unknown 12 \
  --retry-interval 10
# After a successful consumer deploy, record it so the matrix stays accurate
npx pact-broker record-deployment \
  --pacticipant checkout-service \
  --version "$GITHUB_SHA" \
  --environment production \
  --broker-base-url "$PACTFLOW_URL" \
  --broker-token "$PACTFLOW_TOKEN"

Run the same gate and record-deployment pair in the provider’s deploy job, pointed at --pacticipant payment-api. Pair this with breaking change detection in the provider’s spec PR review so OpenAPI-level diffs surface before the spec is ever published, giving a second, earlier layer of safety.

Before/After: CDC vs Bi-Directional Contract Testing

Dimension Classic CDC Bi-Directional (BDCT)
Provider required to participate in CI Yes — must run Pact Verifier No — publishes OpenAPI spec only
Provider must boot in CI Yes No
Works with third-party providers No Yes, if they publish an OpenAPI spec
Works with legacy services Only if they can boot Yes
Verification artefact Live HTTP responses from provider Static OpenAPI spec
Catches runtime semantic bugs Yes (wrong status codes, bad headers at runtime) Only if declared in the spec
Catches spec drift from implementation No (verifies actual behavior) No (trusts the spec is accurate)
Broker requirement Open-source Pact Broker or PactFlow PactFlow only
Consumer Pact generation Identical Identical
Deployment gate (can-i-deploy) Identical Identical

The core trade-off: CDC verifies real runtime behavior; BDCT trusts the spec and avoids provider execution. If the provider’s spec drifts from its implementation, BDCT will not catch it. Mitigate this by requiring the provider team to run automated spec-conformance tests (e.g., Schemathesis or Dredd against their staging environment) so the spec is trustworthy.

Verification

After running both publish steps, PactFlow’s UI shows the cross-validation result in the matrix. The CLI also returns it directly:

npx pact-broker can-i-deploy \
  --pacticipant checkout-service \
  --version 9f3c1a2 \
  --to-environment production

Computer says yes \o/

CONSUMER          | C.VERSION | PROVIDER     | P.VERSION | SUCCESS?
checkout-service  | 9f3c1a2   | payment-api  | d7f1e93   | true

All required verification results are published and successful.
checkout-service version 9f3c1a2 can be deployed to production.

A failing cross-validation looks like:

Computer says no ¯\_(ツ)_/¯

CONSUMER          | C.VERSION | PROVIDER     | P.VERSION | SUCCESS?
checkout-service  | 9f3c1a2   | payment-api  | d7f1e93   | false

REASON:
  Interaction: POST /v1/charges → 201
  Path '/v1/charges' with method 'POST' not found in provider spec.

The reason line identifies the exact interaction and the exact spec mismatch, making the diagnosis self-contained without needing to diff files manually.

Edge Cases and Caveats

The spec must be trustworthy. BDCT validates the consumer against the OpenAPI spec, not against the running provider. If the provider team publishes a spec that does not accurately reflect production behavior — a common problem with code-first teams that auto-generate specs from annotations — BDCT gives a false green. Before relying on BDCT for a provider, confirm they run spec-conformance tests (Schemathesis 3.x, Dredd, or equivalent) in CI as part of their spec publication gate.

Matchers vs schema strictness. Pact’s like() matcher checks type only, not format or constraints. A Pact that uses like('not-a-uuid') for a field the OpenAPI spec marks format: uuid will pass BDCT’s cross-validation because like() only asserts string. This is looser than what the provider will actually accept. Use MatchersV3.regex() or MatchersV3.fromProviderState() for fields with strict format constraints to get cross-validation parity.

Provider-state coverage is narrower. Classic CDC runs each Pact interaction against a provider state seeded specifically for that interaction. BDCT has no concept of provider states — it validates the interaction against the schema regardless of state. This means BDCT cannot catch a bug where a provider returns a different shape for different entity states (e.g., a PENDING order might omit fields that a COMPLETED order includes). If state-dependent shape variance is a real concern for your API, CDC is more appropriate even if the operational cost is higher.

Frequently Asked Questions

What is bi-directional contract testing?

Bi-directional contract testing (BDCT) publishes both a consumer’s recorded Pact and the provider’s own OpenAPI spec to a broker (PactFlow), which then cross-validates them statically. The provider never has to run the consumer’s test suite; instead the spec is the verification artefact.

When should I choose bi-directional contract testing over classic CDC?

Choose BDCT when the provider is a third party, a legacy service you cannot easily instrument, or when the provider team refuses or is unable to integrate the consumer’s test harness into their CI. If you own both sides and the provider boots cleanly in CI, classic CDC gives stronger guarantees.

Does bi-directional contract testing require PactFlow specifically?

The cross-validation algorithm that compares a consumer Pact against a provider OpenAPI spec is currently implemented in PactFlow (the commercial hosted broker). The open-source Pact Broker does not support BDCT out of the box. You can run PactFlow’s Docker image locally for development.

What guarantees does BDCT give that classic CDC does not?

BDCT gives coverage for provider states you did not explicitly record — if the OpenAPI spec says a field is present, the cross-check will catch a consumer that misused it even if no CDC interaction covered it. However, CDC can catch runtime semantic gaps (wrong status codes, unexpected headers) that a static spec comparison cannot.

Can I mix CDC and BDCT in the same pipeline?

Yes. Both verification modes publish results to the same broker and gate the same can-i-deploy check. You assign each consumer-provider edge the mode that fits the relationship and get a unified deployment matrix either way.

How do I handle a provider that publishes multiple OpenAPI spec versions?

Publish each spec version as a separate provider version in the broker, tagged with the branch or release version. Consumers pin to the relevant provider version via version selectors, and can-i-deploy evaluates the exact pair. Never overwrite a spec version that is currently marked as deployed.