One of the attractions of splitting a monolith into smaller services is independence. Services can have clearer ownership, evolve separately and, ultimately, be deployed independently.

But that independence comes at a cost.

Imagine a monolith containing two areas of functionality: Orders and Customers.

MONOLITH

Orders --------> Customers

When both live in the same codebase, many incompatible changes are difficult to make accidentally. Change a Java method signature or remove a type and the compiler may immediately tell you what you have broken. Automated tests run over the same codebase. A change to both sides can be made atomically and deployed as one application.

Now extract Orders into a separate service:

Order Service  ---- HTTP ---->  Customer Service

separate build                 separate build
separate release               separate release
separate deployment            separate deployment

The Java compiler can no longer see across that HTTP boundary.

More importantly, changes are no longer necessarily atomic. Customer Service might be deployed today and Order Service tomorrow. Different versions can coexist across environments. Both builds can be green while the system is broken.

That is the problem contract testing is intended to address.

A contract test is not literally a compiler check across a network. The compiler never protected us from every behavioural incompatibility in the first place. But I find the analogy useful: once code moves behind a service boundary, we lose both compile-time visibility and the safety of atomic change and deployment. We need another way to detect when independently evolving components no longer agree about how they communicate.

What is the contract?

Suppose Order Service calls:

GET /customers/123

and depends on receiving:

{
  "id": "123",
  "name": "Fred"
}

The important word is depends.

Customer Service might actually return much more:

{
  "id": "123",
  "name": "Fred",
  "email": "fred@example.com",
  "dateOfBirth": "1980-01-01",
  "telephone": "01234567890"
}

But if Order Service only uses id and name, those are the fields relevant to this consumer.

A consumer-driven contract captures interactions like this: given a particular situation, when the consumer makes a particular request, what observable behaviour does it rely on from the provider?

That can include the HTTP method and path, required request data, response status, particular response fields and types, headers, or error behaviour that the consumer genuinely needs.

Contracts are generally interaction- or example-based rather than exhaustive descriptions of everything an API could possibly do. Matchers can loosen values where the exact value is irrelevant: a consumer may require an ID to be a string, for example, rather than require it always to be "123".

The aim is not to reproduce the provider’s entire API in every consumer contract. A useful consumer contract should normally be as small as the dependency allows. The consumer should also be tolerant of compatible additions: if it claims only to care about id and name, an unrelated new field should not break its response mapping.

And “consumer-driven” does not mean “consumer dictates”. The consumer owns the requirement, but the interface still has to be agreed with the provider.

The useful bit: verified stubs

Most developers working with distributed systems have used stubs.

If Order Service depends on Customer Service, I do not necessarily want to start a real Customer Service every time I test Orders. I might use WireMock to say:

When:
    GET /customers/123

Return:
    200

    {
      "id": "123",
      "name": "Fred"
    }

Order Service can now be tested independently.

There is an obvious problem:

Who proves that my stub still behaves like the real Customer Service?

A hand-written stub can quietly drift away from reality. Order Service remains green because the fake Customer Service behaves exactly as I told it to behave. Meanwhile the real Customer Service may have changed weeks ago.

Consumer-driven contract testing closes that loop. Conceptually, one contract drives verification on both sides:

                         CONTRACT
                        /        \
                       /          \
                      v            v

              generated stub     provider
                      |           verification
                      |               |
                      v               v

               real consumer      real provider
                 client code          code
                      |               |
                     PASS            PASS

On the consumer side, Order Service runs tests through its real HTTP client or adapter against a stub generated from the contract.

On the provider side, Customer Service runs verification generated from that same contract against its real implementation.

The consumer proves: my code can work with a provider behaving according to this contract.

The provider proves: my implementation actually behaves according to this contract.

The stub is therefore no longer just something somebody wrote because it looked approximately like Customer Service. It is backed by a contract the real provider must also satisfy.

There is symmetry here. If the consumer test mocks its own HTTP client before reaching the generated stub, it proves little about the real consumer interaction. Equally, if provider verification mocks everything immediately behind the controller, it may prove little about the real provider behaviour.

Each side’s verification can run in its own build pipeline. We do not need to deploy both services into a shared environment simply to establish this compatibility.

The consumer owns the requirement. The provider owns the obligation to satisfy it. Where the contract file physically lives is a tooling decision.

Where does this fit with other tests?

Test Main question
Unit Does this small piece of code behave correctly?
Component/service Does this service behave correctly in isolation?
Contract Do consumer and provider still agree about this interaction?
Integration Do real components integrate correctly?
End-to-end Does the complete journey work?

The terminology, particularly “integration test”, varies between teams. The important distinction here is that contract testing does not require the real consumer and provider to be running together.

Nor should contract tests become a second home for all the provider’s business rules. If gold customers receive a 10% discount, that rule probably belongs in the provider’s domain or functional tests. Encoding every business rule into consumer contracts creates duplication and turns internal provider changes into unnecessary cross-team coordination.

Contract tests cannot prove everything either. They may not catch a semantic change where a field retains the same shape but changes meaning, and they do not prove production authentication, networking, deployment configuration or performance.

They provide a specific guarantee about agreed interactions, not a guarantee that the whole distributed system works.

Contract testing while breaking up a monolith

Contract testing becomes particularly interesting during incremental decomposition.

Return to the monolith, but assume Orders is being extracted first while Customers remains inside it:

Order Service  ---- HTTP ---->  MONOLITH
                               |
                               +-- Customers

A tempting solution is simply to expose whatever large aggregate or internal representation the monolith already has.

That gets the new service working, but it can preserve exactly the coupling we were trying to remove.

An alternative is to introduce a deliberately narrow endpoint representing what Order Service actually requires from Customers:

Order Service
     |
     | GET /customers/{id}/order-details
     v
Monolith / Customers

The endpoint is designed around the emerging service boundary rather than the historical structure of the monolith. A consumer-driven contract then makes that boundary executable.

For now, the monolith is the provider:

Order Service ---- contract ----> Monolith / Customers

Later Customers itself is extracted:

Order Service ---- contract ----> Customer Service

The interesting thing is what survives that transition.

It is not necessarily the provider’s test plumbing. The monolith may need one base test class, set of fixtures and dependency setup while the new Customer Service needs completely different infrastructure.

What survives is the consumer requirement.

During migration, the same requirement can potentially be verified against both implementations:

                      CONTRACT
                     /        \
                    v          v

                Monolith     Customer
                provider     Service
                   PASS        PASS

For the interactions represented by that contract, both providers satisfy what Order Service needs.

That does not prove the two implementations are universally equivalent. It proves something narrower and more useful: for this consumer’s contracted behaviour, the new implementation can replace the old one.

In a real decomposition dependencies can run in both directions, so the monolith may itself consume capabilities already extracted into services. Contracts can protect those boundaries too.

There is a cost. The monolith’s build now has additional provider verification to run, which matters if an already-large build is slow. Contract testing is not free; it trades some build and organisational complexity for earlier compatibility feedback.

How much of the provider should a contract test exercise?

This is where the apparently simple idea gets more interesting.

Consider a very shallow provider test:

Contract verification
        |
        v
HTTP controller
        |
        v
mock application service

The mock returns Customer("123", "Fred"), and the contract verifies that the HTTP response contains id and name.

That proves something: the endpoint exists, the request can be handled and the expected response can be serialised.

But it may be close to tautological. We told the mock to return an object containing id and name, then proved that the controller returned id and name.

Worse, the mock can construct a state the real application never produces. Perhaps the real mapping leaves name null or populates a different field. The contract remains green because the canned object bypasses the code containing the defect.

At the other extreme we could run everything:

HTTP
 |
controller
 |
application
 |
domain
 |
database
 |
other services
 |
external infrastructure

Now our focused contract test starts looking like an integration or end-to-end test. Setup becomes larger, execution becomes slower and failures become harder to diagnose.

For a service using hexagonal architecture there is a useful middle ground to consider. Hexagonal architecture separates the application’s core from infrastructure through ports and adapters. In simplified form:

Contract verification
        |
        v
   HTTP adapter
        |
        v
   application
        |
        v
      domain
        |
        v
   outbound port
        |
        X
 controlled dependency

The real HTTP adapter, application and meaningful domain behaviour participate, while dependencies beyond an appropriate outbound boundary are controlled.

That boundary is still a design decision. Persistence could be stubbed behind a repository port, or a real database could participate through something such as Testcontainers. The right choice depends on what behaviour needs to execute for the provider response to be meaningful.

Contract scenarios also need controlled state. If a contract says, conceptually, “given customer 123 exists”, the provider test environment needs some way to establish that condition through fixtures, mocks, an in-memory implementation or real test persistence.

There is no universal depth at which every contract test should stop. A useful principle is:

Exercise enough real provider code that the contracted response is genuinely produced by the application, without turning the contract suite into a duplicate functional or end-to-end test suite.

What about OpenAPI?

OpenAPI and consumer-driven contracts overlap, but they answer different questions.

An OpenAPI description provides a language-agnostic description of an HTTP API: broadly, what does this API expose?

A consumer-driven contract asks something narrower: what behaviour from this provider does this particular consumer rely on?

An API might expose twenty fields while one consumer relies on two.

Both forms of information are useful. OpenAPI can support documentation, client generation, validation and testing. Consumer contracts make specific dependencies between independently evolving systems executable. Neither automatically makes the other unnecessary.

When is consumer-driven contract testing worth it?

Contract testing becomes more valuable as services become genuinely independent.

If consumer and provider are always changed, built and deployed together, there is less opportunity for them to drift apart. If different teams own them, releases happen independently and multiple versions can coexist, compatibility becomes a much more important concern.

That introduces version-management questions. A provider cannot simply decide that because the latest consumer no longer uses a field, the field can immediately disappear. An older consumer version may still be deployed somewhere. Removing behaviour safely requires knowing when consumers have stopped relying on it and often using parallel or expand-and-contract changes during migration.

The consumer has a corresponding responsibility: its tests should use provider stubs representing versions it can actually encounter, rather than blindly assuming the newest snapshot is already deployed everywhere.

There is also an organisational dependency. Consumer-driven contract testing means a provider team accepts that another team’s legitimate consumer requirement can cause its build to fail. That requires agreement about ownership, lifecycle and how incompatible changes are negotiated.

It is not necessarily the right model everywhere. With a third-party API, you can test your assumptions, but you normally cannot require the provider to execute your contract in its build. A public API with unknown consumers has a different relationship with its users and may rely much more heavily on published specifications and versioning discipline.

The value of consumer-driven contract testing comes from cooperation between independently evolving parties.

A concrete Java example: Spring Cloud Contract

The ideas above do not require a particular framework, but Spring Cloud Contract provides a useful example in the Java/Spring ecosystem.

A contract can, for example, be written using its Groovy DSL to describe an interaction:

Given:
    customer 123 exists

When:
    GET /customers/123

Then:
    200 OK

And:
    response contains id and name

Spring Cloud Contract can use those definitions to generate provider-side verification tests and stubs that consumers run against. On the HTTP side, its Stub Runner can start WireMock using the producer’s generated stubs.

This brings us back to the earlier point: instead of maintaining a hand-written WireMock representation of another service and hoping it remains accurate, the consumer stub and provider verification are derived from the same contract.

Spring Cloud Contract also illustrates an initially confusing distinction between who drives the contract and where the contract file lives. Contracts can live on the producer side or in an external repository. In a producer-hosted consumer-driven workflow, a consumer can propose the interaction it needs and test against the resulting stubs before the provider accepts the contract and verifies its implementation.

Consumer owns the requirement
          |
          v
Contract is agreed
          |
          v
Provider owns satisfying it

The physical location of the contract does not determine whether the process is consumer-driven.

The same general principles also apply beyond synchronous HTTP to asynchronous messaging and events, although that deserves a discussion of its own.

The dependency was already there

Splitting a monolith does not remove coupling.

Order Service still needs something from Customer Service. Moving that call from a Java method to HTTP merely changes the form of the dependency — and makes it easier for the two sides to evolve independently into incompatible states.

Without contract testing, much of that dependency exists implicitly inside consumer code and, potentially, inside hand-written stubs.

Consumer-driven contract testing makes it explicit.

The consumer states what it actually relies upon. Its real client code is tested against a stub derived from that requirement. The provider independently proves that its real implementation can satisfy the same requirement.

That does not replace unit, functional, integration or end-to-end testing.

It addresses a much more specific problem created by distributed systems:

making an existing dependency visible to the party capable of breaking it, at the point they are about to break it.


Further reading: Spring Cloud Contract reference documentation and the OpenAPI Specification.

Contract Testing: The Compiler Check You Lose When You Split the Monolith
Tagged on:

Leave a Reply

Your email address will not be published. Required fields are marked *