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

  1. Consumer (Y) writes a test. “When I call X like this, I expect a response like that.”
  2. The test framework records the contract. A JSON document describing request/response.
  3. Y publishes the contract to a shared registry (Pact Broker, Git repo, internal service).
  4. 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.
  5. 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.