Skip to main content
Back to Blog

Pact Contract Testing: Consumer-Driven Contracts for Microservices

9 min read

What happens when a harmless provider change brings down half your production microservices?

Well, last quarter we hit exactly this. A minor update to our Inventory Service removed a deprecated field from a response payload. The change passed code review and every unit test. Within five minutes of going live, our Order Service was throwing NullPointerExceptions in production.

The root cause was simple: an implicit contract between the services had been broken, and nothing in our test suite caught it. This is the problem Pact contract testing solves.

Pact Contract Testing: Consumer-Driven Contracts for Microservices

Why Does Pact Contract Testing Matter?

Microservices give teams autonomy, and that autonomy comes with a hidden cost: integration drift.

You see, each service evolves on its own schedule, so a seemingly harmless payload change is often enough to break downstream consumers. End-to-end tests are slow, brittle and very, very expensive to maintain. Unit tests are blind to cross-service dependencies.

Pact fills that gap. It formalizes the expectations between consumers and providers as executable contracts. The consumer defines what it needs, the provider verifies it can deliver, and the whole cycle runs in CI. If either side breaks the contract, the build fails before anything reaches production.

What Are the Core Concepts?

Pact uses consumer-driven contracts. The consumer writes a test describing the exact requests it makes and the responses it expects. From that test, Pact generates a contract file, usually just called the pact. The provider then verifies it can fulfil every interaction in that contract.

The rules that follow from this:

  • Contracts are written from the consumer's perspective.
  • Providers must satisfy the contracts of all their consumers.
  • Contracts are versioned and shared through a broker, such as Pact Broker or PactFlow.
  • Both sides run in CI, which gives fast feedback without deploying anything.

This flips the traditional integration testing model. Instead of slow, environment-heavy end-to-end suites, you get focused verification on the interactions that actually matter.

What Should You Put in a Contract, and What Should You Avoid?

This is where most teams get it wrong. A contract is not a schema definition. It is a statement of what the consumer actually uses.

Include:

  • Fields the consumer reads, and their types. Use matchers, not exact values.
  • HTTP status codes the consumer handles, such as 200 and 404.
  • Required headers, for example Content-Type and the authorization scheme.
  • The error response shapes the consumer handles explicitly.

Leave out:

  • Fields the consumer ignores. The provider should stay free to add or change those.
  • Exact values for dynamic data. Use type or regex matchers instead.
  • Internal provider state or database structure.
  • Performance expectations like latency or throughput. Contracts are about shape, not speed.

Discipline is what matters here. Over-specifying makes contracts brittle and blocks providers from evolving. Under-specifying defeats the point. A good contract captures the minimum set of assumptions the consumer needs to work.

How Do You Implement Pact With Java and Spring Boot?

Take two services that talk to each other:

  • Order Service (consumer): places orders and checks inventory.
  • Inventory Service (provider): returns stock levels for a given SKU.

Writing a Consumer Contract Test With JUnit 5 and pact-jvm

The Order Service team writes a Pact test describing exactly how it calls the Inventory Service.

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "InventoryService", port = "8080")
class InventoryClientPactTest {
 
    @Pact(consumer = "OrderService")
    V4Pact inventoryPact(PactDslWithProvider builder) {
        return builder
            .given("SKU 123 exists and is in stock")
            .uponReceiving("a request for inventory of SKU 123")
            .path("/inventory/sku123")
            .method("GET")
            .willRespondWith()
            .status(200)
            .body(newJsonBody(body -> {
                body.stringType("sku", "sku123");
                body.integerType("quantity", 10);
            }).build())
            .toPact(V4Pact.class);
    }
 
    @Test
    @PactTestFor(pactMethod = "inventoryPact")
    void shouldReturnInventory(MockServer mockServer) {
        var response = new RestTemplate()
            .getForEntity(mockServer.getUrl() + "/inventory/sku123",
                InventoryResponse.class);
 
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().getQuantity()).isGreaterThan(0);
    }
}
JAVA

Note the stringType and integerType matchers. The test verifies structure and data types, not hardcoded values, and that is what keeps contracts stable across environments.

Running this test generates a Pact JSON file, which is then published to your broker.

How Provider Verification Works in Spring Boot

On the provider side, the Inventory Service runs verification against every published consumer contract.

@Provider("InventoryService")
@PactBroker(url = "${PACT_BROKER_URL}")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class InventoryProviderPactTest {
 
    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }
 
    @State("SKU 123 exists and is in stock")
    void setupInventory() {
        inventoryRepository.save(new Inventory("sku123", 10));
    }
}
JAVA

The @State method prepares the test data the provider state in the consumer's contract asks for. Pact replays each interaction and checks that the provider's actual response matches what the consumer expects.

If the Inventory Service renames quantity to qty, this test fails immediately and the provider build breaks long before deployment.

Where Does Pact Break?

Pact is not an almighty silver bullet. These are the failure modes I have seen in practice:

  • Implicit contracts: if the consumer depends on field ordering, default values or undocumented behaviour, no contract will capture it. Make those assumptions explicit.
  • Uncovered endpoints: Pact only verifies interactions that consumers write tests for. A provider endpoint with no consumer test is invisible.
  • Test data drift: provider verification relies on seeded data. If your @State setup diverges from real production state, you get false confidence.
  • Schema evolution deadlocks: removing a field three consumers rely on means coordinating three teams. Without a deprecation process, providers get stuck.
  • Versioning complexity: multiple consumers at different contract versions create a compatibility matrix. Without strict version management in the broker, it becomes hard to reason about.

When Should You Avoid Pact?

Pact adds process and tooling overhead, and it is not always worth it.

  • Single-consumer internal APIs: one consumer, same team, and a shared integration test or a well-maintained OpenAPI spec may be enough.
  • Highly dynamic responses: polymorphic data or user-generated content, such as a GraphQL endpoint with arbitrary field selection, is hard to express as a fixed contract.
  • Legacy services without test infrastructure: bolting Pact onto a system with no CI pipeline is a large effort. Fix the foundation first.
  • Event-driven messaging: Pact supports message-based testing for Kafka and AMQP, but it is less mature than the HTTP side. Check it fits your patterns before committing.

If your system has fewer than five services and one team owns all of them, the coordination cost will outweigh the benefit. Pact earns its place when multiple teams deploy independently and cannot coordinate every release.

How Do You Introduce Pact in a Real Organization?

Adopting Pact everywhere at once creates friction. A phased rollout works better.

Phase 1: pick one critical API boundary. Choose two services with a history of integration failures. Have the consumer team write two or three contract tests covering the most important interactions. Publish to a broker and run provider verification manually at first.

Phase 2: integrate into CI. Wire consumer pact publishing and provider verification into your pipelines. Use the can-i-deploy check from the Pact Broker CLI to gate deployments. From here, a change that breaks a consumer contract fails the provider build.

Phase 3: expand coverage. Add contract tests for other consumers and providers, prioritising APIs with multiple consumers or a history of breakage. Resist contract-testing everything. Focus on boundaries where independent deployment creates real risk.

Phase 4: enforce and maintain. Make contract verification a required CI check rather than advisory. Set up alerts for stale contracts. Establish a deprecation process: when a provider needs to drop a field, consumers get a timeline to update before the old contract is removed from the broker.

The can-i-deploy command is the deployment gate throughout. It asks the broker to confirm the version you are about to deploy is compatible with every currently deployed consumer and provider version. If it is not, the deploy is blocked.

Honestly, phases 3 and 4 are where most teams stall. Once you are past a handful of services and into dozens of contracts, the hard parts stop being technical: broker hygiene, contract versioning, who owns a breaking change, and how a deprecation lands across teams. That territory is covered in Beyond Pact: Scaling Consumer-Driven Contract Testing in Large Organizations.

Is Your Setup Production-Ready?

Before you call a Pact setup production-ready, check:

  • Every consumer has contract tests for its critical provider interactions
  • Pact files are published to a centralized broker on every consumer CI run
  • Provider verification runs in CI and fails the build on contract violations
  • The can-i-deploy check gates deployments for both consumers and providers
  • @State and provider state setup is maintained and reviewed regularly
  • A deprecation process exists for removing or renaming provider fields
  • Stale or orphaned contracts are pruned from the broker on a schedule
  • Consumer and provider teams review contracts together before breaking changes
  • Contract coverage is tracked and gaps in critical paths are visible

Conclusion

Pact contract testing is a pragmatic safety net for teams that deploy microservices independently. It catches integration drift before it reaches production, without the slow, environment-heavy end-to-end suites that most teams end up abandoning.

The trade-off is upfront investment in tooling and team discipline. The payoff is fewer production outages and more confidence in every release.

Start with one critical API boundary. Keep contracts lean. Automate verification, gate deployments on it, and expand from there.

Related Articles

Browse All Articles