Series overview
Part 23 of 2882% complete
2026-07-10•16 min read

Contract testing and consumer-driven contracts

Since Chapter 2, order-service’s tests have stubbed inventory-service’s REST contract with WireMock — useful, but with a known gap that chapter flagged and deferred: nothing verifies that WireMock’s stub actually still matches what inventory-service really returns. This chapter closes that gap with Pact.

1. Problem the Pattern Solves

inventory-service’s team ships a refactor: ReservationResponse’s unavailableSkus field is renamed to unavailableItems to better match a broader effort to standardize field names across the platform. Every one of inventory-service’s own tests pass — the rename is internally consistent. order-service’s WireMock-based test (Chapter 2) also still passes, because the WireMock stub, written by hand months ago, still returns the old field name unavailableSkus — it has no actual connection to inventory-service’s real implementation, and nothing catches the mismatch until order-service’s production deployment starts receiving inventory-service’s new response shape and silently fails to parse the field it’s looking for.

This is exactly the risk Chapter 2 named at the time: “a hand-rolled version; the dedicated contract-testing chapter later replaces it with Pact.” Two independently deployed services, each with fully green test suites, can still break each other in production the moment their shared contract drifts, because neither team’s tests actually verify the other team’s real behavior — only their own team’s guess at it.

Forces in tension:

  • Test speed and independence vs. real contract verification. WireMock stubs are fast and don’t require the real dependency running — but they can drift from reality silently, exactly as this incident shows, since nothing keeps them synchronized automatically.
  • Full end-to-end integration tests vs. practicality. Testing against every real dependency for every build (Testcontainers running all of Northwind’s services together) would catch this drift, but at a cost and slowness that makes it impractical to run on every commit, for every service, especially as the number of services grows.
  • Consumer needs vs. provider’s freedom to evolve. The consumer (order-service) needs confidence the provider (inventory-service) won’t break its contract; the provider needs freedom to refactor internals and even evolve its API, provided it doesn’t break fields consumers actually depend on — contract testing’s whole value is making this negotiation explicit and automated instead of implicit and hopeful.
  • Coordination cost vs. catching drift early. Contract tests require the provider team to run the consumer’s contract verification in their own CI pipeline — a real, ongoing coordination cost between teams, in exchange for catching exactly this class of break before it reaches production.

2. Core Idea

Consumer-driven contract testing means the consumer of an API (order-service) defines a contract — a precise specification of the requests it will make and the responses it expects — and the provider (inventory-service) runs that exact contract against its real implementation in its own CI pipeline, failing the build if its actual behavior no longer satisfies what the consumer specified. This inverts the usual assumption that only the provider defines its own API’s correctness; here, correctness is defined by what consumers actually depend on.

inventory-service's CI

order-service's CI

published to

pulled by

pass/fail

Consumer test against a Pact mock server

Generates pact.json:

'I send X, I expect Y back'

Pact Broker

inventory-service's CI

Replay every interaction from pact.json

against the REAL inventory-service

Build fails if inventory-service

no longer satisfies order-service's contract

inventory-service's CI

order-service's CI

published to

pulled by

pass/fail

Consumer test against a Pact mock server

Generates pact.json:

'I send X, I expect Y back'

Pact Broker

inventory-service's CI

Replay every interaction from pact.json

against the REAL inventory-service

Build fails if inventory-service

no longer satisfies order-service's contract

Participants:

  • Consumer — order-service, which writes a test expressing exactly what it expects from inventory-service’s API, generating a contract file (a “pact”) as a byproduct of that test.
  • Provider — inventory-service, which retrieves the pact (via a shared Pact Broker) and verifies its real implementation still satisfies every interaction the consumer specified — this runs in inventory-service’s own CI, not order-service’s.
  • Pact Broker — a shared service (self-hosted or Pactflow) storing published contracts and verification results, and providing the “can I safely deploy this?” query both teams use before releasing.

Commonly confused with:

  • The WireMock-based test from Chapter 2. That test verified order-service’s own client code handles a given response shape correctly — valuable, but entirely one-sided, since nothing connected the stub back to inventory-service’s actual behavior. This chapter’s Pact test replaces the stub with a contract that inventory-service is obligated to verify — the missing other half.
  • A shared OpenAPI/Swagger specification. An OpenAPI spec documents an API’s shape, often written and maintained by the provider alone — useful documentation, but nothing enforces that the spec and the real implementation stay in sync unless something (contract testing, or spec-driven code generation with strict verification) actually checks it. Consumer-driven contracts flip the authorship: the consumer’s actual usage defines what must not break, not a provider-authored document that may or may not reflect every consumer’s real dependency.
  • End-to-end integration tests. A full end-to-end test (spinning up order-service and a real inventory-service together) verifies the same kind of thing contract testing does, but at far higher cost and slower feedback — contract testing gets most of the same protection without either team needing the other’s full service running locally or in CI.

3. When to Use It

Strong indicators:

  • Two or more independently deployed services with a direct API dependency (essentially every service pair in this series since Chapter 2) — the exact situation where one team’s change can silently break another’s production behavior.
  • A demonstrated or plausible risk of exactly Section 1’s incident — a provider’s internal refactor silently changing a response shape a consumer’s hand-written stub doesn’t reflect.
  • Independent deployment cadences across teams (this series’ whole premise since Chapter 2) — if order-service and inventory-service always deployed together, in lockstep, a shared integration test suite might suffice; independent deployability is precisely what makes contract drift a real, ongoing risk rather than a one-time integration concern.

Concrete use cases:

  • Any REST or gRPC service boundary between independently owned services, which describes most of this series’ architecture since Chapter 2 — order-service/inventory-service, order-service/payment-service, and the BFF-to-service boundaries from Chapter 4 are all candidates.
  • Organizations with many teams and services, where the number of pairwise dependencies grows faster than any team can manually track — contract testing scales this coordination problem better than tribal knowledge or documentation alone.
  • APIs consumed by external partners, where a full end-to-end test against the partner’s system usually isn’t possible, but a contract capturing the partner’s actual expectations (if they’re willing to participate) can still catch breaking changes before they ship.
  • Migrating or versioning an API (relevant to Chapter 13’s Strangler Fig work): a contract test verifies a new implementation genuinely satisfies what existing consumers depend on, a more rigorous check than shadow-traffic comparison alone.

Prerequisites:

  • Both teams’ buy-in and CI integration — a contract test only provides its safety guarantee if the provider actually runs verification in their pipeline and treats a failure as a real, build-blocking signal, not an ignorable warning.
  • A shared Pact Broker (or equivalent) both teams’ CI pipelines can reach — additional shared infrastructure to operate and keep available.
  • A clear policy for what happens when a verification fails — does it block the provider’s deploy, the consumer’s next contract change, or trigger a conversation between teams? This needs to be agreed before the first real conflict, not improvised during one.

4. When Not to Use It

  • A single team owns both the consumer and the provider, deployed together. If order-service and inventory-service were owned and released by the same team in lockstep, the coordination overhead of a full consumer-driven contract workflow may exceed its benefit — a shared integration test suite, or even just careful code review, might suffice.
  • An API with no external consumers at all, purely internal to one service with no cross-service dependency — there’s no “consumer” in the relevant sense, and contract testing has nothing to verify.
  • Overengineering signal: writing contract tests for every internal method or every trivial, stable field, rather than focusing on the actual API boundary and the fields consumers genuinely depend on. A contract with excessive, over-specified detail becomes as brittle and high-maintenance as the tight coupling it’s meant to prevent.
  • Risk: treating a passing contract verification as equivalent to full correctness — contract tests verify shape and basic behavior consumers depend on; they don’t replace inventory-service’s own business-logic tests (Chapter 1’s unit tests) or genuine end-to-end tests for scenarios contract testing doesn’t cover (true concurrent behavior, real network partition handling, and so on).

5. Implementation Example

The consumer test, in order-service, defining the exact contract it depends on — this replaces Chapter 2’s hand-written WireMock stub entirely:

order-service/build.gradle.kts
dependencies {
testImplementation("au.com.dius.pact.consumer:junit5:4.6.16")
}
order-service/src/test/kotlin/in/o612/eng/northwind/order/internal/InventoryServicePactTest.kt
package `in`.o612.eng.northwind.order.internal
import au.com.dius.pact.consumer.dsl.PactDslWithProvider
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt
import au.com.dius.pact.consumer.junit5.PactTestFor
import au.com.dius.pact.core.model.RequestResponsePact
import au.com.dius.pact.core.model.annotations.Pact
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.Test
import org.springframework.web.client.RestClient
import java.util.UUID
@ExtendWith(PactConsumerTestExt::class)
class InventoryServicePactTest {
@Pact(consumer = "order-service", provider = "inventory-service")
fun stockUnavailablePact(builder: PactDslWithProvider): RequestResponsePact =
builder
.given("SKU WIDGET-1 has insufficient stock")
.uponReceiving("a reservation request that cannot be fulfilled")
.path("/api/v1/reservations")
.method("POST")
.body("""{"orderId":"11111111-1111-1111-1111-111111111111","items":[{"sku":"WIDGET-1","quantity":5}]}""")
.willRespondWith()
.status(409)
// The exact contract: order-service depends specifically on
// `unavailableSkus` existing — this is the field whose silent
// rename caused Section 1's incident.
.body("""{"orderId":"11111111-1111-1111-1111-111111111111","status":"UNAVAILABLE","unavailableSkus":["WIDGET-1"]}""")
.toPact()
@Test
@PactTestFor(pactMethod = "stockUnavailablePact")
fun `client correctly interprets an unavailable-stock response`() {
val client = InventoryClient(RestClient.create("http://localhost:8080"))
val outcome = client.reserveStock(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
listOf(ReservationItemDto("WIDGET-1", 5)),
)
require(outcome is ReservationOutcome.Unavailable && outcome.skus == listOf("WIDGET-1"))
}
}

Running this test both verifies order-service’s own client code against a mock server and generates a pact.json file — a precise, machine-readable specification of the exact request/response interaction order-service depends on, published to the shared Pact Broker as part of CI:

order-service CI pipeline (relevant step)
./gradlew test pactPublish -Ppact.broker.url=https://pact-broker.northwind.internal

The provider verification, running in inventory-service’s own CI — the crucial other half Chapter 2 was missing:

inventory-service/build.gradle.kts
dependencies {
testImplementation("au.com.dius.pact.provider:junit5spring:4.6.16")
}
inventory-service/src/test/kotlin/in/o612/eng/northwind/inventory/api/InventoryServiceProviderPactTest.kt
package `in`.o612.eng.northwind.inventory.api
import au.com.dius.pact.provider.junit5.HttpTestTarget
import au.com.dius.pact.provider.junit5.PactVerificationContext
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider
import au.com.dius.pact.provider.junitsupport.Provider
import au.com.dius.pact.provider.junitsupport.State
import au.com.dius.pact.provider.junitsupport.loader.PactBroker
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestTemplate
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.web.server.LocalServerPort
@Provider("inventory-service")
@PactBroker(url = "https://pact-broker.northwind.internal")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(PactVerificationInvocationContextProvider::class)
class InventoryServiceProviderPactTest {
@LocalServerPort lateinit var port: Integer
@BeforeEach
fun setTarget(context: PactVerificationContext) {
context.target = HttpTestTarget("localhost", port.toInt())
}
@State("SKU WIDGET-1 has insufficient stock")
fun setupInsufficientStock() {
// Provider states set up the real data needed for the contract's
// scenario — here, seeding the real database so WIDGET-1 genuinely
// has insufficient stock when the pact's request actually arrives.
testDataSetup.seedStock("WIDGET-1", availableQuantity = 0)
}
@TestTemplate
fun pactVerificationTestTemplate(context: PactVerificationContext) {
// Replays every interaction from every consumer's published pact
// against the REAL, running inventory-service — this is what would
// have caught Section 1's silent field rename immediately.
context.verifyInteraction()
}
}

This test runs inventory-service’s actual Spring Boot application, sets up real data matching the pact’s specified state, and replays order-service’s exact recorded request against it — verifying the real response genuinely contains unavailableSkus, not a hand-maintained guess at what it should contain. A field rename to unavailableItems now fails this build immediately, in inventory-service’s own CI, before the change ever reaches a shared environment.

6. Step-by-Step Flow

order-service CI (earlier)Pact Brokerinventory-service CIinventory-service developerorder-service CI (earlier)Pact Brokerinventory-service CIinventory-service developerEarlier: order-service's consumer testpublished its pact to the brokerCaught in inventory-service's own CI,before any deploy, before Section 1's incident could happenrenames unavailableSkus -> unavailableItemspush changefetch order-service's published pactrun provider verification against real inventory-servicereplay recorded request, compare response shapeBUILD FAILS: unavailableSkus missing from response
order-service CI (earlier)Pact Brokerinventory-service CIinventory-service developerorder-service CI (earlier)Pact Brokerinventory-service CIinventory-service developerEarlier: order-service's consumer testpublished its pact to the brokerCaught in inventory-service's own CI,before any deploy, before Section 1's incident could happenrenames unavailableSkus -> unavailableItemspush changefetch order-service's published pactrun provider verification against real inventory-servicereplay recorded request, compare response shapeBUILD FAILS: unavailableSkus missing from response
  1. Client action (of this workflow, not an end-user request): an inventory-service engineer makes what seems like an internal, harmless field rename.
  2. API request equivalent. inventory-service’s CI pipeline, as part of its normal build, fetches every consumer’s currently-published pact from the broker.
  3. Service behavior. The provider verification test seeds the exact state each pact interaction specifies (Section 5’s @State annotation) and replays the exact recorded request against the real, running service.
  4. Database interaction. The @BeforeEach state setup writes real data to a real (test) database — this is a genuine integration test, not a mock, which is precisely why it catches what a hand-written stub cannot.
  5. Inter-service communication. None beyond the verification itself — no live call to order-service is needed; the pact file already captured everything order-service depends on.
  6. Error or failure handling. The verification fails immediately, in inventory-service’s own CI, with a clear diff showing exactly which expected field is missing — turning a would-be production incident into a local, pre-merge build failure.
  7. Observability signals. The Pact Broker’s dashboard shows the verification result against this specific version of inventory-service, and can answer “is it safe to deploy version X of inventory-service given every currently-deployed consumer’s contract” — a genuinely useful deploy-time gate.
  8. Final response/outcome. The engineer either reverts the rename or coordinates the change properly (dual-supporting both field names during a transition, exactly the kind of API evolution discipline Chapter 2 called for) — either way, the break never reaches production.

7. Production Concerns

  • Timeouts, retries, idempotency. Not directly relevant to the contract-testing mechanism itself, though provider states (Section 5’s @State) should be idempotent and safely re-runnable, since CI may retry a flaky verification run.
  • Data consistency. Provider verification runs against a real (test) database seeded per interaction — keep this test data isolated and reset between runs, exactly as any integration test requires, to avoid one interaction’s setup leaking into another’s verification.
  • API versioning and backward compatibility. Contract testing is, in a real sense, this series’ versioning discipline (referenced since Chapter 2) made automatically enforced rather than manually remembered — a provider can still evolve its API freely as long as every currently-active consumer’s contract keeps passing.
  • Authentication and service-to-service trust. The Pact Broker itself needs access control — contracts can reveal API shape and internal naming conventions that shouldn’t necessarily be public, even within a large organization.
  • Logging, metrics, tracing. Track contract verification pass/fail rate over time as its own metric — a rising failure rate across many verifications might indicate a provider team is making breaking changes faster than consumers can adapt, a process signal worth surfacing beyond individual build failures.
  • Kubernetes deployment. The Pact Broker needs its own deployment and availability plan, same as any shared infrastructure this series has introduced (the config server in Chapter 5, the OTel Collector in Chapter 22) — a broker outage blocks every team’s CI pipeline simultaneously if verification is a hard gate.
  • Testing strategy. Contract tests complement, not replace, unit tests (Chapter 1) and end-to-end tests (Chapter 1’s Testcontainers-based integration test) — each catches a different class of problem; contract testing specifically catches cross-service API drift between independently deployed teams.
  • Migration strategy. Introduce contract testing for the highest-risk, most frequently-changing service boundaries first — Northwind started with order-service/inventory-service given Section 1’s actual incident — expanding to other pairs (order-service/payment-service, the BFF boundaries from Chapter 4) incrementally as each pair’s coordination is set up.

8. Common Mistakes

  1. Treating a passing contract verification as full correctness. Assuming inventory-service’s business logic (does it actually decrement stock correctly under concurrent load) is verified by a contract test that only checks response shape and status codes. Fix: keep contract tests focused on the API boundary; business-logic correctness remains the job of the provider’s own unit and integration tests (Chapter 1).
  2. Over-specifying the contract with irrelevant detail. A pact asserting on every single field’s exact value, including ones order-service doesn’t actually use, makes the contract brittle to changes that don’t actually matter to the consumer. Fix: specify only what the consumer genuinely depends on — Section 5’s pact checks unavailableSkus specifically because that’s the field that caused a real incident, not every field in the response.
  3. Not running provider verification in the provider’s own CI. Publishing consumer contracts but never actually gating the provider’s build on verifying them defeats the entire mechanism — it becomes documentation nobody enforces. Fix: provider verification must be a required, build-blocking CI step, exactly as Section 5 configures it.
  4. No agreed process for a verification failure. When inventory-service’s CI fails a contract verification, if there’s no clear next step (revert? negotiate with the consumer team? version the API?), the failure becomes a source of friction rather than useful signal. Fix: establish, before the first real conflict, what happens when a verification fails — this series’ emphasis on explicit versioning discipline (Chapter 2 onward) applies directly here.
  5. Using contract testing as a substitute for end-to-end tests entirely. Contract tests verify API shape and interaction correctness in isolation; they don’t catch genuine multi-service integration issues like network timeouts, real concurrent load, or a saga’s actual multi-step behavior (Chapter 11) under real conditions. Fix: keep a smaller number of true end-to-end tests for scenarios contract testing structurally can’t cover, alongside (not instead of) contract tests.
  6. Letting the Pact Broker become an unmonitored, unavailable dependency. If the broker goes down and nobody notices, every team’s CI silently stops enforcing contract verification (or worse, blocks every build) without visibility into why. Fix: monitor the broker’s own availability with the same rigor as any other shared platform infrastructure in this series.

9. Decision Guide

Problem signalUse this pattern?WhyAlternative
Two independently deployed services with a direct API dependencyYesCatches contract drift automatically, before it reaches production—
A provider’s internal refactor has previously broken a consumer silentlyYesThis is the exact, demonstrated failure mode this pattern exists to prevent—
Consumer and provider are owned by the same team, deployed in lockstepMaybe notCoordination overhead may exceed benefit at this tight a couplingShared integration test suite, careful code review
No cross-service API dependency existsNoNothing to verify a contract against—
Need to verify genuine multi-service runtime behavior (timeouts, concurrency, saga steps)No, not aloneContract tests verify shape/interaction, not full runtime behavior under real conditionsEnd-to-end tests, alongside contract tests

10. Hands-On Exercise

Extend it: write a consumer-driven contract for order-service’s dependency on payment-service’s charge-capture endpoint (from Chapter 11’s saga implementation), including both the success and the declined-payment response shapes as separate pact interactions.

Simulate a failure: deliberately rename a field in inventory-service’s real response (reproducing Section 1’s incident exactly) and confirm the provider verification test fails with a clear, specific diff — then revert the change and confirm the verification passes again.

Decision question, with justification required: inventory-service’s team wants to add a genuinely new, optional field (estimatedRestockDate) to the reservation response that no current consumer depends on yet. Does this require any change to existing contracts, and would you expect the provider verification to pass or fail for this specific kind of change? Use this to articulate, in your own words, what makes a change “safe” versus “breaking” under consumer-driven contract testing.

11. Key Takeaways

  • Consumer-driven contract testing has the consumer define exactly what it depends on, and requires the provider to verify that contract against its real implementation in its own CI — catching exactly the kind of silent API drift a hand-written stub (Chapter 2’s WireMock test) cannot.
  • The mechanism only provides its guarantee if the provider’s CI actually runs verification as a required, build-blocking step — a published-but-unverified contract is just unenforced documentation.
  • Specify only what the consumer genuinely depends on, not every field’s exact value — an over-specified contract is as brittle as the tight coupling this pattern exists to prevent.
  • Contract tests complement, not replace, unit tests and end-to-end tests — each catches a different class of problem, and contract testing’s specific strength is cross-team, cross-deployment API drift.
  • A shared Pact Broker is new, shared infrastructure that both teams’ CI pipelines depend on — it needs its own availability plan, exactly like the config server, OTel Collector, or any other shared platform component this series has introduced.
  • Establish a clear process for what happens when a verification fails before the first real conflict occurs — without one, failures become friction rather than useful, actionable signal.
  • Adding a genuinely new, optional field is normally a safe, non-breaking change under this pattern; removing or renaming a field any consumer’s contract specifies is what triggers a failure — internalizing this distinction is the pattern’s practical payoff.
Spring BootKotlinMicroservices

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind