Consumer-Driven Contracts — Pact Basics

9 min read

Pact is the dominant consumer-driven contract testing framework. It has bindings for Java, JavaScript, Python, Go, .NET, Ruby, and several others — most large microservice teams that take contract testing seriously end up using it. This lesson is the practical follow-up to the previous one: how Pact actually works in code, what a Pact file looks like on disk, the role of the Pact Broker, and the "can I deploy?" check that ties it all together. You don't need to install Pact to follow along; the goal is fluency, not setup.

The Pact lifecycle

Five stages, repeated on every PR on either side. We'll walk through each.

Stage 1: the consumer test

The consumer writes a test that says "when I make this request, I expect this response." Pact provides a mock provider that records the expectation and serves the canned response so the consumer's own logic can be tested against it.

In conceptual form (Pact's actual syntax varies by language):

provider("user-service")
  .uponReceiving("a request for user 123")
  .withRequest({
    method: "GET",
    path: "/users/123",
    headers: { "Authorization": "Bearer ..." }
  })
  .willRespondWith({
    status: 200,
    headers: { "Content-Type": "application/json" },
    body: {
      id: 123,
      name: like("Alice"),
      email: matchesRegex("^.+@.+$"),
      role: oneOf("admin", "editor", "viewer")
    }
  })

A few things worth flagging:

  • like("Alice") says "any string is fine — it doesn't have to be Alice." The shape matters; the exact value doesn't.
  • matchesRegex(...) constrains the shape further when needed.
  • oneOf(...) captures enum-style fields.

These matchers are the heart of consumer-driven contracts: the consumer pins down what shape it relies on while leaving wiggle room for the provider's actual data.

When the consumer test runs, it hits the Pact mock (not the real provider), confirms the consumer's logic handles the recorded shape, and writes the interaction to disk as a Pact file.

Stage 2: the Pact file

A Pact file is a JSON document like:

{
  "consumer": { "name": "checkout-frontend" },
  "provider": { "name": "user-service" },
  "interactions": [
    {
      "description": "a request for user 123",
      "request": {
        "method": "GET",
        "path": "/users/123"
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "body": {
          "id": 123,
          "name": "Alice",
          "email": "alice@test.com",
          "role": "admin"
        },
        "matchingRules": {
          "$.body.name": { "match": "type" },
          "$.body.email": { "match": "regex", "regex": "^.+@.+$" }
        }
      }
    }
  ]
}

Two things to notice:

  • The file describes both an example response ("name": "Alice") and matching rules — "match by type" means any string is acceptable; "match by regex" applies the pattern.
  • It's just JSON. A future tool, language, or test framework can read it without knowing about Pact internals.

Stage 3: the Pact Broker

In a small project you can pass Pact files between repos by hand. In any real organisation, that breaks down. The Pact Broker is a service that:

  • Stores Pact files, tagged with consumer name, provider name, and version.
  • Tracks verification results — "provider X v3.2 successfully verified the contract with consumer Y v1.7."
  • Shows a compatibility matrix across all your services.
  • Powers the can-i-deploy query.

Two common deployments:

  • Pactflow — hosted SaaS, the simplest path.
  • Self-hosted broker — the open-source pact-broker image, deployed in your own infra.

Either way, the broker becomes the single source of truth about which consumer/provider versions are compatible.

Stage 4: provider verification

On the provider's CI, a verification step runs:

pact-verifier \
  --provider-base-url=https://users-service.staging \
  --pact-broker-url=https://broker.example.com \
  --provider=user-service \
  --provider-version=$GIT_SHA

Pact fetches every contract for user-service from the broker, replays each interaction against the running provider, and:

  • For each interaction, sends the recorded request.
  • Compares the actual response to the recorded expected response using the matching rules.
  • Reports per-interaction pass/fail back to the broker.

A failed verification means: "consumer X expects something the provider can't deliver any more." That's the breaking-change signal, and it shows up on the provider's CI — exactly where it's actionable.

Stage 5: can-i-deploy

The crowning piece. Before deploying any service, the CI runs:

pact-broker can-i-deploy \
  --pacticipant=user-service \
  --version=$GIT_SHA \
  --to-environment=production

This asks the broker: "for every consumer of user-service in production, is this version of the provider compatible with their currently-deployed version?"

  • Yes → safe to deploy.
  • No → deploy is blocked; the broker shows which consumer would break.

That single check turns "we hope this deploy is safe" into "the broker confirms it is." It's the operational payoff of all the upstream work.

State handlers — what makes provider verification work

A subtle but essential piece: the provider needs the right data in place to satisfy each interaction. The contract says "GET /users/123 returns Alice" — but Alice has to exist in the provider's database when verification runs.

Pact handles this with provider states. The consumer test declares a state (given("user 123 exists")); the provider's verification setup includes a state handler that creates user 123 before the interaction is replayed, and cleans up afterwards.

In code (conceptual):

provider.beforeEach(state => {
  if (state === "user 123 exists") {
    db.insert("users", { id: 123, name: "Alice", email: "alice@test.com" });
  }
});

Without state handlers, contract verification fails on data that doesn't exist. With them, every interaction has a known starting state.

What Pact doesn't cover

Worth being explicit:

  • Business logic — Pact verifies shape, not "the discount is calculated correctly."
  • End-to-end flows — pairwise contracts, not multi-hop sequences.
  • Performance — a slow provider verifies fine; a contract test won't tell you about latency.
  • Truly dynamic data — fields that change from request to request need careful matcher design.

Pact is one tool in the suite, not the whole strategy. Pair it with functional tests, performance tests, and end-to-end smoke tests to get full coverage.

⚠️ Common mistakes

  • Forgetting matching rules. Hardcoding "name": "Alice" in a contract makes it brittle. Use like() and let the provider return any matching shape.
  • No provider states. "User 123 exists" can't just be assumed. State handlers must create the data the contract expects.
  • Skipping can-i-deploy. Without it, a green provider verification only proves a single moment in time. The deploy gate is what makes contracts operational.

🎯 Practice task

Walk through Pact's "Hello World" example end to end. 30-45 minutes.

  1. Open the Pact "Getting Started" guide in your preferred language. Read through, don't install yet.
  2. Identify each of the five lifecycle stages above in the example. Match the code in the guide to "consumer test", "Pact file", and so on.
  3. Sketch a single interaction for an endpoint in your own project. Decide which fields use exact-match vs like() vs regex.
  4. Sketch the provider state handler that would set up the data needed.
  5. Read the Pact Broker documentation — focus on can-i-deploy. Understand what it checks and how it knows.
  6. Stretch: if your team uses Pact, find a real Pact file in your repo. Open it. Identify the matchers. Try to predict what would happen if the provider renamed a field — which interactions would fail verification?

The next lesson moves to a different style of contract: API documentation expressed as OpenAPI/Swagger.

// tip to track lessons you complete and pick up where you left off across devices.