Integration testing microservices hurts. Spinning up five services to verify one endpoint call is slow, flaky, and expensive. Consumer-driven contracts offer a better deal: each service verifies its contract independently, and breaking changes are caught in CI before anyone deploys.
The problem
Team A owns service X. Team B owns service Y, which calls X. Team A changes X’s API. Two weeks later, Y breaks in production.
Classical solutions:
- End-to-end tests covering both (slow, flaky, requires coordinated environments)
- Shared staging (chicken-and-egg problems, blocks teams)
- Strong central governance (political, slow)
Consumer-driven contracts: Y publishes what it expects from X. X’s build runs those contracts as tests. If X’s change breaks Y’s expectations, X’s build fails — on X’s side, in X’s CI, before anything ships.
The workflow
- Consumer (Y) writes a test. “When I call X like this, I expect a response like that.”
- The test framework records the contract. A JSON document describing request/response.
- Y publishes the contract to a shared registry (Pact Broker, Git repo, internal service).
- Producer (X) runs all contracts as part of its build. X spins up a test instance, replays each consumer’s request, verifies its response matches.
- Break = fail. If X changes its API in a way that doesn’t match Y’s contract, X’s build fails.
The key insight: the consumer describes its expectations, not the producer’s behavior. Producer only needs to keep the consumer’s happy paths working.
A Pact example
Consumer side (Java with Pact-JVM):
@ExtendWith(PactConsumerTestExt.class)
class OrderClientPactTest {
@Pact(consumer = "shipping", provider = "orders")
RequestResponsePact getOrder(PactDslWithProvider builder) {
return builder
.given("order 123 exists")
.uponReceiving("get order 123")
.path("/orders/123")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("id", "123")
.numberType("amountCents", 4999)
.stringValue("status", "PAID"))
.toPact();
}
@Test
@PactTestFor(pactMethod = "getOrder")
void canGetOrder(MockServer mockServer) {
OrderClient client = new OrderClient(mockServer.getUrl());
Order order = client.get("123");
assertThat(order.status()).isEqualTo("PAID");
}
}After the test runs, a JSON contract is written to target/pacts/. Upload it to a Pact Broker.
Producer side:
@Provider("orders")
@PactBroker(url = "https://pacts.company.internal")
class OrdersProviderPactTest {
@BeforeEach
void setup(PactVerificationContext ctx) {
ctx.setTarget(new HttpTestTarget("localhost", 8080));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verify(PactVerificationContext ctx) { ctx.verifyInteraction(); }
@State("order 123 exists")
void order123Exists() {
orderRepo.save(new Order("123", 4999, Status.PAID));
}
}Producer’s CI pulls all published contracts from the broker and verifies each.
Contracts vs schemas
Contracts are more than schemas:
- A schema says “this endpoint accepts fields X, Y, Z”
- A contract says “this consumer uses fields X and Y, ignores Z, expects value A in specific circumstances”
Schemas detect structural changes. Contracts detect relevant changes — the ones that matter to a consumer. You can add fields without breaking any contract (new fields are ignored). Remove a field only one consumer uses — their contract breaks, others don’t.
This is more precise than schema checking and catches exactly the things that matter.
Spring Cloud Contract alternative
For Spring-heavy shops, Spring Cloud Contract works similarly but in the other direction: producer defines stubs, consumers use them. Technically producer-driven rather than consumer-driven, but the end-to-end effect is similar.
My preference: Pact for cross-language, Spring Cloud Contract when everyone is Spring.
Organizational benefits
- Teams decouple. Y can develop against X’s contract without a running X.
- Breaking changes are local. X’s team sees failures in X’s CI, not as surprise incidents.
- Documentation is implicit. The contract is the spec.
- Onboarding speeds up. New consumer reads existing contracts to learn the API.
Common mistakes
Testing implementation, not contract. “Consumer expects field orderCode to equal ‘ORD-’ + hashedId”. Now the producer can’t change the hashing algorithm. Contracts should test behavior at the interface, not internal details.
Too many verbose contracts. One contract per controller method × N consumers = thousands of tests. Consolidate where possible.
Ignoring the broker. Contracts sitting in consumer repos don’t help producers. A shared Pact broker with web UI lets producers see what all consumers expect, including versions.
Not wiring into CI/CD. A contract test that isn’t blocking deploys is just documentation. Make it block.
When not to use contract testing
- Single-producer, single-consumer pairs in one team — simpler to coordinate directly
- Extremely unstable APIs in early exploration — contracts add friction
- Systems where integration tests are already cheap (small, fast, reliable) — don’t double up
For any system with 5+ services owned by multiple teams, contract testing pays back quickly.
Closing note
Contract testing isn’t a replacement for integration tests — it’s a replacement for most integration tests. You still want some true end-to-end coverage for critical flows (checkout, payment). But the 100-test E2E suite becomes a 5-test suite plus per-service contracts, which run fast, find real problems, and let teams move independently. That’s the real win.