All articles
169 articles · updated weekly See our Tools
All articles
Tutorials

Contract testing between services

Contract testing between services: understand the benefits, limitations and when it's worth adopting in your distributed architecture.

Contract testing between services
COVER · Tutorials

The field that vanished without warning

Friday afternoon, the payments team renamed a field from transaction_id to txn_id in their API response. Smooth deploy, their service's tests passing, nobody complained. On Monday, the billing service — which consumes that API — started writing null to every transaction. No error, no 500, just silently broken data. The integration tests didn't catch it because they ran against a mock that still had the old field. That's the classic problem contract testing exists to solve: the provider changes the payload and the consumer finds out in production.

Here I explain what contract testing is, how it differs from schema validation and E2E, and when it's worth it. It's a direct complement to automated API testing — where API tests guarantee each service works, the contract guarantees they keep agreeing with each other.

What a contract test actually tests

A contract is an explicit agreement about the shape of communication between two services: which fields the request has, which fields the response returns, types, whether they're required. The contract test verifies that provider and consumer keep honoring that agreement — independently, without having to stand both up together.

The trick is that the contract is verified from both sides, at separate times. The consumer declares what it expects. The provider proves it delivers exactly that. If one side breaks the agreement, the test fails at that service's build time, not in production three days later.

Consumer-driven contracts (Pact style)

The approach that gained traction is the consumer-driven contract, popularized by Pact. The logic: the consumer dictates the contract, because the consumer is the one who suffers when the response changes. The consumer writes a test describing the expected interaction, and that test generates a contract file (the "pact").

{
  "description": "GET /transactions/42 returns a transaction",
  "request": { "method": "GET", "path": "/transactions/42" },
  "response": {
    "status": 200,
    "body": {
      "transaction_id": "string",
      "amount": "number",
      "currency": "string"
    }
  }
}

That contract is published to a broker, and the provider has to run provider verification: it takes each consumer's contract and runs the requests against the real implementation, checking that the response matches. If the payments team had this, their build would have failed on the exact commit that renamed transaction_id, because billing's contract still expected the old name. The bug would never have reached Monday.

The win isn't just catching the break — it's catching it before deploy, in the right side's CI, with a clear message telling you which consumer you were about to break.

A contract is not schema validation

This is where a lot of confusion lives. Schema validation checks whether a specific payload matches an expected structure — you can take a response and validate it against a JSON Schema using a JSON Schema Validator to confirm the types and required fields are right. That's great, but it's a one-off test of a single payload.

The contract is more than that: it ties two ends together. The schema says "this JSON is valid". The contract says "service A expects exactly this, and service B promises to deliver exactly this, and both builds will fail if one breaks the promise". Schema is structure; contract is a bilateral agreement verified in both CIs. In practice you use schema inside the contract — the definition of the expected body is, at heart, often a schema. But isolated validation doesn't warn you that you broke the consumer; it only tells you whether a payload you already hold is well-formed.

Contract versus E2E

The obvious alternative for guaranteeing services talk is the end-to-end test: stand everything up, fire a real request, check the final result. It works, but it has two chronic problems — it's slow (booting N services, a database, queues) and it's flaky (any network or timing hiccup fails the test for a reason that isn't a bug). An E2E suite that takes 40 minutes and fails 1 in 5 runs for random reasons is a suite the team learns to ignore.

Contract testing solves the "do the services agree on the shape" part quickly and deterministically, without standing anything up together. Each side tests against the contract in isolation, in milliseconds. This doesn't replace E2E for critical end-to-end business flows — but it takes off E2E's plate the job of catching shape breaks, which is precisely the most common and cheapest case to catch early.

When it's worth it and when it's overkill

Contracts shine in microservice architectures with several teams deploying independently. That's exactly where the silent break happens: different teams, different deploy cycles, nobody with the full picture of who consumes what.

  • Worth it: multiple services, multiple teams, independent deploys, HTTP/messaging integrations across team boundaries.
  • Overkill: a monolith, or a single team owning both provider and consumer that always deploy together. There the type system and ordinary integration tests already cover it; adding a broker, contracts and verification is ceremony with no payoff.

The rule of thumb: contracts pay off when the boundary between services is also a boundary between teams or between deploy cycles. When provider and consumer live in the same repository and ship together, the compiler is your contract.

Frequently asked questions

Does contract testing replace integration tests?

No. It covers a specific slice: shape compatibility between provider and consumer. You still need unit, integration and E2E tests for critical flows. The contract takes off E2E the burden of catching payload breaks, but it doesn't test end-to-end business logic.

Do I need a tool like Pact or can I do it by hand?

You can start by hand with a shared schema validated on both sides, but you lose the most valuable part: automatic verification of the provider against the real contracts of all consumers via a broker. Tools like Pact exist to manage that choreography — which contract version each consumer published and against which provider version it was verified.

Consumer-driven or provider-driven?

Consumer-driven is the default because the consumer is the one who breaks when the response changes — it makes sense for it to declare what it needs. Provider-driven makes sense when the provider is a product with many unknown consumers (a public API), where the provider publishes the contract and consumers adapt.

Does a contract catch a change that breaks behavior, not just shape?

Not directly. A contract verifies the shape and presence of fields. If the provider keeps amount as a number but starts returning the value in cents instead of dollars, the contract stays green — the type didn't change. For that you need behavior tests; the contract covers structure, not semantics.

What this changes in your architecture

Contract testing isn't just another test layer for the sake of it — it's the direct answer to the silent break between services that independent teams inevitably cause. It catches the error in the right side's CI, before deploy, with a clear message, and does it fast and without flakiness, unlike E2E. Use schema to validate isolated payloads, contracts to tie provider and consumer together, and E2E only for the few critical end-to-end flows worth the cost. And remember the rule: if the service boundary isn't a team boundary or a deploy boundary, it's probably overkill — save the ceremony for where it pays rent.

RD
Author
Rafael Duarte
Backend developer with a background in fintech and B2B SaaS — worked on teams that scaled APIs from zero to millions of requests. Carries enough production scars to hold strong opinions on tools, patterns and architecture decisions. Not an academic: he read the UUID RFC when he had to choose between v4 and v7 for a write-heavy table.
View profile