Software Engineer · Java · Spring Boot · Microservices
Consumer-Driven Contract Testing with Pact: Preventing Breaking Changes Across Microservices
Breaking API changes between microservices are among the most expensive and embarrassing production incidents in distributed systems. A team renames a JSON field, their CI passes, and three hours later a downstream service they'd never heard of starts throwing NullPointerException in production. Consumer-driven contract testing with Pact is the engineering discipline that catches these failures at the provider's CI pipeline — before a single byte ever reaches production. This article walks through the full Pact workflow for Java/Spring Boot teams, from writing consumer pacts to enforcing can-i-deploy gates in GitHub Actions.
Table of Contents
- The Breaking Change Problem in Microservices APIs
- Contract Testing vs Integration Testing: When to Use Each
- Pact Architecture: Consumers, Providers, and the Pact Broker
- Writing Consumer Pacts in Java with Spring Boot
- Provider Verification: Ensuring Contracts Are Honored
- Pact Broker: Centralized Contract Management and Can-I-Deploy
- CI/CD Integration: Enforcing Contracts at Every Deployment
- Production Failure Scenarios: What Contract Testing Prevents
- Trade-offs and When NOT to Use Contract Testing
1. The Breaking Change Problem in Microservices APIs
In a large e-commerce platform, the Inventory team owns the /inventory/{productId} endpoint. The Orders service consumes it to display real-time stock availability on the checkout page. In a routine refactor, the Inventory team renames the response field stockLevel to quantityAvailable — a reasonable improvement in naming clarity. Their unit tests pass, their integration tests pass (they test their own API shape), and the change ships to production.
Three hours later, the on-call engineer for the Orders team gets paged. The checkout page is failing with a NullPointerException deep inside a Jackson deserialization path. The Orders service's StockInfo model has a field named stockLevel annotated with @JsonProperty("stockLevel"). The renamed field from Inventory now deserializes to null, and the downstream null check that everyone assumed would never be hit is hit. Revenue impact: $40k in lost transactions before the rollback completes.
stockLevel in the API response. The Inventory team wasn't negligent — they genuinely didn't know. In a platform with 40+ microservices, it is operationally impossible to manually track all consumer field-level dependencies. Contract testing encodes that knowledge as machine-readable artifacts and enforces it in CI.
With Pact in place, the Orders service would have published a pact file declaring: "I expect the response to contain an integer field named stockLevel." When the Inventory team's PR renamed the field, the provider verification step in their pipeline would have fetched that pact from the Pact Broker and replayed it against the new code — failing immediately with a clear message: "Pact verification failed: expected field 'stockLevel' not found in response." The breaking change would have been blocked at the source, not discovered in production.
2. Contract Testing vs Integration Testing: When to Use Each
The most common misconception when introducing Pact is that it replaces integration tests. It does not, and trying to make it do so leads to frustration. The two test types answer fundamentally different questions and require different infrastructure.
Integration tests verify that two services behave correctly together in a realistic environment. They require both services to be running, a shared database or message broker, and proper network connectivity. They test business behavior: does the checkout flow correctly reserve inventory when an order is placed? They are slow (minutes), expensive to maintain, and flaky under infrastructure degradation. But they are the only way to validate end-to-end business scenarios.
Contract tests verify that the API shape agreement between a consumer and a provider has not been violated. They run entirely in CI without any running infrastructure — the consumer test runs against a Pact mock server, and the provider test spins up only the provider service itself (no downstream dependencies required). They execute in seconds, are deterministic, and provide precise, actionable failure messages about which field or status code broke which consumer.
A useful mental model: contract tests are like a type system for your API boundaries. Just as a compiler catches type mismatches before the program runs, contract tests catch API shape mismatches before the service deploys. Integration tests are like acceptance tests — they verify the compiled program behaves correctly, which the type system alone cannot guarantee.
3. Pact Architecture: Consumers, Providers, and the Pact Broker
The Pact ecosystem revolves around three actors. The consumer is the service that makes HTTP calls to another service. The provider is the service that exposes the API the consumer calls. The Pact Broker is a centralized server (open source or PactFlow SaaS) that stores pact files, tracks which consumer versions have been verified by which provider versions, and exposes the can-i-deploy query.
The full workflow in a two-team platform looks like this:
Consumer (Orders Service)
│
├── Writes consumer test using @Pact annotation
│ Defines: expected request shape + expected response shape
│
├── Pact library generates: OrderService-InventoryService.json (pact file)
│
└── CI publishes pact file to Pact Broker
│
▼
Pact Broker (centralized store)
│
├── Stores pact file tagged with consumer version + branch
│
└── Provider pipeline fetches pact on every build
│
▼
Provider (Inventory Service)
│
├── @PactBroker annotation fetches all consumer pacts
├── Replays each interaction against the real provider code
├── @State methods set up provider-side test data
│
└── Reports verification results back to Pact Broker
│
▼
can-i-deploy gate
│
└── Checks: has this consumer version been verified by
all providers it depends on? Only then allow deployment.
The critical insight in this flow is that the consumer drives the contract. The consumer team writes tests that express exactly what they depend on from the provider — not what the provider's full API surface is. This is why the pattern is called consumer-driven. The provider is only obligated to satisfy what consumers have declared they need, making API evolution much safer: providers can freely add new fields (non-breaking), but cannot remove or rename fields that consumers have pacted on.
4. Writing Consumer Pacts in Java with Spring Boot
The Pact JVM library integrates with JUnit 5 through a dedicated extension. Add au.com.dius.pact.consumer:junit5 to your pom.xml and write a test class that declares the expected interaction. The consumer test runs entirely against a Pact-managed mock server — the real InventoryService is never contacted.
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "InventoryService", port = "8080")
class OrderServiceConsumerTest {
@Pact(consumer = "OrderService", provider = "InventoryService")
public RequestResponsePact getStockLevel(PactDslWithProvider builder) {
return builder
.given("product 123 exists in inventory")
.uponReceiving("a request for product stock level")
.path("/inventory/123")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.integerType("stockLevel", 50)
.stringType("sku", "PROD-123"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "getStockLevel")
void shouldFetchStockLevel(MockServer mockServer) {
InventoryClient client = new InventoryClient(mockServer.getUrl());
StockInfo stock = client.getStock("123");
assertThat(stock.getStockLevel()).isEqualTo(50);
}
}
Several design decisions in this test are worth examining carefully. The @Pact method uses integerType("stockLevel", 50) — not integerValue(50). The integerType matcher tells Pact: "I care that this field exists and is an integer; I don't care about its exact value." This is the right default for most fields. If you use integerValue, the pact becomes fragile: a provider returning 49 instead of 50 would fail verification even though the consumer's parsing logic would handle any integer correctly.
The given("product 123 exists in inventory") call defines a provider state — a named precondition that the provider's test infrastructure must set up before replaying this interaction. Provider states decouple the consumer test from provider-specific data setup: the consumer declares the precondition in plain English, and the provider team implements the @State method that makes it true.
When this test runs, the Pact library generates a JSON file at target/pacts/OrderService-InventoryService.json. This file is the pact — a machine-readable record of the contract between these two services. It is published to the Pact Broker as part of the CI pipeline.
5. Provider Verification: Ensuring Contracts Are Honored
On the provider side, the Pact JVM library fetches all pact files that consumers have published for this provider, then replays each recorded interaction against the running provider service. The provider test only needs the provider application itself — no consumer services, no shared databases beyond the provider's own test fixtures.
@Provider("InventoryService")
@PactBroker(url = "${PACT_BROKER_URL}")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class InventoryServiceProviderTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@State("product 123 exists in inventory")
void productExists() {
// Set up test data: ensure product 123 is in the test database
inventoryRepository.save(new Product("123", "PROD-123", 50));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTestTemplate(PactVerificationContext context) {
context.verifyInteraction();
}
}
The @PactBroker annotation instructs the Pact library to fetch all pact files for InventoryService from the broker at runtime. If ten different consumer services have published pacts for InventoryService, this single test class verifies all ten in one run. Each @State method name must exactly match the provider state string declared in the consumer's given() call — this is the handshake that links consumer test setup to provider test setup without any shared code between teams.
When verification passes, the Pact library automatically publishes the verification result back to the broker, recording: "InventoryService version X.Y.Z has successfully verified the pact from OrderService version A.B.C." This verification record is what the can-i-deploy command queries. A failed verification blocks the can-i-deploy gate for the provider — preventing the breaking change from reaching production even if the provider team doesn't notice the test failure manually.
@PactFolder (local pact files) in your provider CI pipeline. Always use @PactBroker so the pipeline fetches the latest published pacts from all consumer teams. Local pact files fall out of sync and defeat the purpose of consumer-driven contracts.
6. Pact Broker: Centralized Contract Management and Can-I-Deploy
The Pact Broker is the glue that connects consumer teams and provider teams without requiring them to coordinate directly. It stores every published pact file, tracks verification results across versions and environments, and exposes a queryable API for deployment decisions. The open-source broker is available as a Docker image; PactFlow adds RBAC, webhooks, and a polished UI on top.
The most important capability the broker provides is the can-i-deploy command. Before deploying any service to production, your CI pipeline should run:
# Check whether OrderService version $VERSION can safely deploy to production
pact-broker can-i-deploy \
--pacticipant OrderService \
--version $VERSION \
--to-environment production \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKEN
This command queries the broker's knowledge graph to determine: for every pact that OrderService version $VERSION has published, has the provider it targets verified it, and is that verified provider version currently deployed in the production environment? If the answer is yes for all providers, the command exits with code 0 and deployment proceeds. If any provider has not verified this consumer's pact, or if the verified provider version is not in production, the command exits with a non-zero code and a human-readable explanation of exactly which pact verification is missing.
The --to-environment production flag is important: it leverages Pact's environment tracking feature. When you deploy a service, you record it in the broker with pact-broker record-deployment --pacticipant InventoryService --version $VERSION --environment production. The broker then knows which version of each service is running in each environment, enabling can-i-deploy to reason about deployment safety with full environmental context rather than just "has this pact ever been verified."
7. CI/CD Integration: Enforcing Contracts at Every Deployment
The following GitHub Actions workflow demonstrates the complete Pact pipeline for a consumer service. The key phases are: run consumer tests (which generate pact files), publish pact files to the broker, trigger provider verification via a broker webhook, and then gate the production deployment on can-i-deploy.
name: Orders Service CI/CD
on:
push:
branches: [main]
pull_request:
jobs:
test-and-publish-pacts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Run consumer tests (generates pact files)
run: mvn test -Dtest="*ConsumerTest"
- name: Publish pacts to broker
run: |
mvn pact:publish \
-Dpact.broker.url=${{ secrets.PACT_BROKER_URL }} \
-Dpact.broker.token=${{ secrets.PACT_BROKER_TOKEN }} \
-Dpact.consumer.version=${{ github.sha }} \
-Dpact.tag=${{ github.ref_name }}
deploy-to-production:
runs-on: ubuntu-latest
needs: test-and-publish-pacts
if: github.ref == 'refs/heads/main'
steps:
- name: Install Pact CLI
run: |
curl -LO https://github.com/pact-foundation/pact-ruby-standalone/releases/download/v2.4.3/pact-2.4.3-linux-x86_64.tar.gz
tar xzf pact-2.4.3-linux-x86_64.tar.gz
echo "$PWD/pact/bin" >> $GITHUB_PATH
- name: Can I deploy? (contract gate)
run: |
pact-broker can-i-deploy \
--pacticipant OrderService \
--version ${{ github.sha }} \
--to-environment production \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}
- name: Deploy to production
run: ./deploy.sh production
- name: Record deployment in Pact Broker
run: |
pact-broker record-deployment \
--pacticipant OrderService \
--version ${{ github.sha }} \
--environment production \
--broker-base-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}
can-i-deploy step must run after provider verification has completed and before any deployment step. It is the last safety check before code reaches production. If can-i-deploy passes, you have machine-verified evidence that every provider this consumer depends on has honored the published contract at the version currently running in production. Skipping this step is equivalent to disabling your smoke tests.
8. Production Failure Scenarios: What Contract Testing Prevents
Three real categories of breaking changes that contract testing reliably catches before they reach production:
Scenario 1 — Renamed JSON field breaking consumer parsing. This is the canonical example: the Inventory team renames stockLevel to quantityAvailable. The consumer's pact declares integerType("stockLevel"). During provider verification, the Pact library sends the recorded request to the provider, receives the response with quantityAvailable, and fails the JSON body assertion: expected field stockLevel not present. The provider pipeline fails. The can-i-deploy gate for the provider blocks deployment. The consumer team is notified via broker webhook before a single production request is affected.
Scenario 2 — Changed HTTP status code on delete endpoint. The Shipping service changes its DELETE /shipment/{id} endpoint from returning HTTP 200 with a body to returning HTTP 204 with no body. The Orders service consumer test declares .willRespondWith().status(200). Provider verification fails immediately: expected status 200, received 204. This failure is caught even though both 200 and 204 are technically "success" codes — the consumer's code had a branch that read the response body on 200, which would throw on an empty 204 body.
Scenario 3 — Removed optional field that consumer relied on. The Pricing service's API documentation marks the discountCode field as optional. A provider-side refactor removes it entirely from responses for products without discounts. The consumer's pact had included stringType("discountCode") in its expected response body. Because the Pact body matchers enforce that declared fields must be present (even if typed loosely), the provider verification fails when the field is absent. The consumer team is forced to decide: do they truly need this field, or can they update their pact to remove it? Either decision is made deliberately, not discovered in production via a null pointer.
9. Trade-offs and When NOT to Use Contract Testing
Contract testing with Pact is a powerful discipline, but it carries genuine costs and has clear boundaries beyond which it loses value. Understanding these trade-offs before adopting it organization-wide prevents the frustration of misapplied tooling.
Contract tests are contract-first, not behavior-first. A passing Pact verification does not mean the endpoint behaves correctly under load, returns semantically correct data, or handles edge cases properly. It only means the API shape matches what the consumer declared. A provider could return stockLevel: 0 for every product and pass all Pact verifications — Pact uses type matchers, not business logic assertions. Contract tests must be complemented by proper unit, integration, and E2E tests.
For event-driven systems using Kafka or RabbitMQ, Pact supports message pacts via the @PactTestFor(providerType = ProviderType.ASYNCH) annotation. Instead of verifying HTTP request/response interactions, message pacts verify the schema of messages published to a topic. The consumer declares what message structure it can process, and the provider verifies it can produce messages matching that structure. If your architecture is predominantly event-driven, invest in understanding Pact's message pact support before concluding that Pact doesn't apply to your stack.
When NOT to use contract testing:
- Internal monolith modules: If the "consumer" and "provider" are packages within the same deployable JAR, the overhead of Pact workflow (pact file generation, broker publishing, verification pipelines) far exceeds the value. The compiler and your module-level unit tests are sufficient guards for intra-monolith boundaries.
- Third-party APIs you don't control: You cannot run provider verification against a third-party REST API like Stripe or Twilio. Contract testing requires provider cooperation. Use API schema validation (JSON Schema, OpenAPI) and mock-based consumer tests for third-party dependencies instead.
- Highly volatile APIs in early development: If the API shape changes weekly during active feature development, the overhead of updating pacts and re-publishing on every change slows teams down. Wait until the API has stabilized before introducing Pact. Use OpenAPI schema sharing as a lighter-weight contract mechanism during the volatile phase.
Key Takeaways
- Contract tests are the compiler for your API boundaries — they encode consumer field-level dependencies as machine-readable pact files and enforce them automatically at the provider's CI pipeline.
- The consumer drives the contract — only fields the consumer actually uses are pacted on, making API evolution safe: providers can freely add new fields without breaking anyone.
- can-i-deploy is your production safety gate — run it as the last step before any production deployment to verify that all provider verifications for this consumer version are satisfied in the target environment.
- Provider states decouple teams — the consumer declares preconditions in plain English; the provider implements them with
@Statemethods, with no shared test code required between teams. - Use type matchers, not value matchers —
integerTypeinstead ofintegerValuemakes pacts resilient to data variation while still catching structural breaking changes like field renames or type changes. - Pact complements, not replaces, integration and E2E tests — contract tests guard API shape; integration tests validate business behavior; both are necessary in a mature microservices testing strategy.
Conclusion
Consumer-driven contract testing with Pact is one of the highest-leverage testing investments a microservices platform can make. The field rename incident described at the start of this article — and the hundreds of similar incidents that happen every month across the industry — is not a people problem. Engineers don't maliciously rename fields. It is a systems problem: there is no feedback loop that tells a provider team which exact fields their consumers depend on. Pact creates that feedback loop, automates it, and enforces it at the exact point where breaking changes originate: the provider's CI pipeline, not the production incident queue.
Start with a single consumer-provider pair where breaking changes have caused past incidents. Write the consumer pact, set up the Pact Broker (the Docker image is a five-minute setup), add provider verification to the provider's pipeline, and wire up can-i-deploy as a deployment gate. Once the first team experiences the workflow — catching a breaking change in CI that would have paged an on-call engineer at 2am — adoption accelerates organically. The goal is not 100% pact coverage on day one; it is building the organizational habit of encoding API shape dependencies as testable, version-tracked artifacts that prevent production failures before they happen.
Discussion / Comments
Related Posts
Microservices Patterns
Deep dive into service decomposition, communication patterns, and resilience strategies for microservices architectures.
gRPC Streaming in Microservices
Implement bidirectional streaming, server-side push, and efficient binary RPC communication between microservices with gRPC.
Kafka Schema Registry
Enforce Avro/Protobuf schema compatibility across Kafka topics and prevent breaking changes in event-driven pipelines.
Last updated: March 2026 — Written by Md Sanwar Hossain