Skip to main content
Back to Blog

Pact Contract Testing: Consumer-Driven Contracts for Microservices

8 min read

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

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 all unit tests. Within five minutes of deployment, our Order Service started throwing NullPointerExceptions in production. The root cause was simple: an implicit contract between 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 Pact Contract Testing Matters

Microservices give teams autonomy. That autonomy comes with a cost: integration drift. Each service evolves on its own schedule, and even trivial payload changes can break downstream consumers. End-to-end tests are slow, brittle, and expensive to maintain. Unit tests are blind to cross-service dependencies.

Pact fills this 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.

Core Concepts

Pact uses consumer-driven contracts. The consumer writes a test describing the requests it makes and the responses it expects. Pact generates a contract file (the "pact") from that test. The provider then verifies it can fulfill every interaction in the contract.

  • Contracts are written from the consumer's perspective.
  • Providers must satisfy all contracts from all their consumers.
  • Contracts are versioned and shared through a broker (Pact Broker or PactFlow).
  • Both consumer and provider tests run in CI, giving fast feedback without deploying anything.

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

What to Put in a Contract (and What to 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 (200, 404, etc.)
  • Required headers (e.g., Content-Type, Authorization scheme)
  • Error response shapes the consumer explicitly handles

Avoid:

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

The discipline here matters. Over-specifying contracts makes them brittle and blocks providers from evolving. Under-specifying them defeats the purpose. A good contract captures the minimum set of assumptions the consumer needs to function correctly.

Pact Contract Testing with Java and Spring Boot

Let's make this concrete. You have two services:

  • Order Service (consumer): places orders, checks inventory.
  • Inventory Service (provider): returns stock levels for SKUs.

Consumer Contract Test (JUnit 5 + pact-jvm)

The Order Service team writes a Pact test describing 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 use of stringType and integerType matchers. The test verifies structure and types, not hardcoded values. This is what keeps contracts stable across environments.

Running this test generates a pact JSON file that gets published to your broker.

Provider Verification (Spring Boot)

On the provider side, the Inventory Service runs verification against all published consumer contracts:

@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 sets up test data matching the provider state declared in the consumer contract. Pact replays each interaction and verifies the provider's actual response matches what the consumer expects. If the Inventory Service renames quantity to qty, this test fails and the provider build breaks before deployment.

Where This Breaks: Failure Modes

Pact is not a 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 behavior, no contract will capture that. You need to make assumptions explicit.
  • Uncovered endpoints: Pact only verifies interactions that consumers write tests for. Provider endpoints with no consumer tests are 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 that three consumers rely on requires coordinating all 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 your broker, this becomes hard to reason about.

Tradeoffs: When Not to Use Pact

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

  • Single-consumer internal APIs: If a service has one consumer owned by the same team, a shared integration test or a well-maintained OpenAPI spec may be enough.
  • Highly dynamic responses: APIs returning polymorphic or user-generated content (e.g., GraphQL with arbitrary field selection) are hard to express as fixed contracts.
  • Legacy services without test infrastructure: Retrofitting Pact onto a service with no CI pipeline or test harness is a large effort. Fix the foundation first.
  • Event-driven messaging: Pact has message-based testing support for Kafka and AMQP, but it is less mature than the HTTP support. Evaluate whether it fits your messaging patterns before committing.

If your system has fewer than five services and a single team owns all of them, the coordination cost of Pact likely outweighs the benefit. Pact shines when teams deploy independently and cannot coordinate every release.

Rollout Plan: Introducing Pact in a Real Org

Do not try to adopt Pact across all services at once. Here is a phased approach that works.

Phase 1: Pick one critical API boundary. Choose two services with a history of integration failures. Have the consumer team write 2-3 contract tests covering the most important interactions. Publish to a broker. Run provider verification manually at first.

Phase 2: Integrate into CI. Wire consumer pact publishing and provider verification into your CI pipelines. Use the "can-i-deploy" check from the Pact Broker CLI to gate deployments. At this stage, the provider build should fail if it breaks any consumer contract.

Phase 3: Expand coverage. Add contract tests for additional consumers and providers. Prioritize APIs with multiple consumers or a history of breakage. Resist the urge to contract-test everything. Focus on boundaries where independent deployments create real risk.

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

The key deployment gate is the can-i-deploy command. It checks the broker to confirm that the version you are about to deploy is compatible with all currently deployed consumer and provider versions. If it is not, the deploy is blocked.

Operational Checklist

Before you call your Pact setup production-ready, verify:

  • 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 / 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 requiring slow end-to-end test environments. The tradeoff is upfront investment in tooling and team discipline, but the payoff is fewer outages and more confident releases.

Start with one critical API boundary. Keep contracts lean. Automate verification. Gate your deployments. Expand from there.

Related Articles

Browse All Articles