Service Contracts and Versioning: Expand-Contract, Tolerant Reader and Consumer-Driven Contract Tests
Whether a change is breaking can't be decided from the provider's code alone; you need to know what consumers read, so consumers act as tolerant readers, the provider splits breaking changes into expand-contract steps, and consumer-driven contract tests show whom a change would break before it is deployed. Real Pact verification output between a TypeScript consumer and a .NET provider, Deprecation/Sunset headers, and the cost of versioning at the service level.
Version note: The code in this post was built and run with C# 14 and .NET 10; the contract tests ran with PactNet 5.0.1 (provider) and @pact-foundation/pact 17.1.4 on Node.js 22 (consumer).
Guess First
In Bookstore, Catalog is now a separate service and returns a book as this JSON (JavaScript Object Notation): { "id": "...", "title": "Dune", "author": "Frank Herbert", "listPrice": { "amount": 14.90, "currency": "EUR" } }. The Catalog team wants to support books with several authors, and the sprint plan has two items. The first removes the author string and replaces it with an authors array: "authors": [ { "name": "Frank Herbert" } ]. The second removes listPrice, which they believe nobody uses any more, from this endpoint and moves it to a separate price endpoint.
Three services call this endpoint: Ordering writes the book title and price into the order line, Notification, written by the Customer Experience team in Node.js and TypeScript, puts the title and author into the order email, and the BFF (Backend for Frontend) behind the Angular storefront fills the detail page. All of them deploy independently of Catalog. Which of the two changes breaks whom? And how can the Catalog team find out before deploying: by posting "does anyone use this field?" in the team channel, or from a test that turns red in the pipeline?
The Two Faces of a Contract
A service's contract is everything it promises to the outside world: endpoints, request and response schemas, status codes, headers, the events it publishes, even expectations such as "this call returns within 300 ms". In the first post of this series we put information hiding at the heart of a microservice; the contract is the boundary between the hidden internal model and the face it shows to the outside. You can change the internal model as much as you like; changing the contract means touching other teams' production.
Ian Robinson described this boundary from both sides in "Consumer-Driven Contracts: A Service Evolution Pattern", published on martinfowler.com in 2006. The provider contract is the complete, authoritative definition of everything a service offers; there is exactly one. A consumer contract is the subset a single consumer actually expects from the provider; there are as many as there are consumers, and none of them is complete. What Notification expects from Catalog is just title and author; it doesn't even know listPrice exists. A consumer-driven contract is the union of these expectations: the provider doesn't have to preserve something nobody uses, but it can't silently break something somebody does. The answer to Guess First follows from this: removing author breaks Notification and the BFF, removing listPrice breaks Ordering. The only reliable way for the Catalog team to know this is for those expectations to live somewhere a machine can read.
Breaking and Non-Breaking Changes
In an API (Application Programming Interface) speaking JSON over HTTP (Hypertext Transfer Protocol), changes roughly fall into these groups:
| Change | For existing consumers | Note |
|---|---|---|
| Adding a field to a response | Safe | If the consumer is a tolerant reader |
| New endpoint, new optional query parameter | Safe | The default behaviour must not change |
| Removing or renaming a response field | Breaks | Doesn't break if nobody reads it; you need to know that |
| Changing a field's type (string → object) | Breaks | Do it under a new field name |
| Adding a required field to a request | Breaks | Old consumers don't send that field |
| Adding a value to an enum | Often breaks | The consumer may reject an unknown value |
| Changing a status code or the error format | Breaks | Such as 200 with an empty body instead of 404 |
| Changing a field's meaning | Breaks silently | No schema tool can catch it |
Two rows of the table depend on a condition, and both lead to the same question: what does the consumer do? Adding a field is safe if the consumer ignores fields it doesn't know. Removing a field is safe if no consumer reads it. So whether a change is breaking can't be decided by looking at the provider's code alone; you need to know what the consumers read. The rest of this post covers both sides: the consumer's tolerance and the provider knowing its consumers.
Tolerant Reader: The Consumer's Share
The stance Martin Fowler named Tolerant Reader in 2011 is the service-to-service version of Jon Postel's robustness principle ("be conservative in what you send, liberal in what you accept"): the consumer reads only the fields it needs, ignores the rest, and doesn't fail when an unknown field shows up. Notification's Catalog client does exactly that:
// Notification declares only the fields it needs (tolerant reader)
export interface BookForEmail {
title: string;
author: string;
}
export async function getBookForEmail(baseUrl: string, bookId: string): Promise<BookForEmail> {
const response = await fetch(`${baseUrl}/api/books/${bookId}`, { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`Catalog returned ${response.status}`);
const body = await response.json();
return { title: body.title, author: body.author };
}
There are two decisions here. First, Notification doesn't declare Catalog's entire Book model, only the two fields it needs; if Catalog adds isbn, pageCount or authors tomorrow, this code is unaffected. Second, the type Notification holds is its own definition, not a shared package generated from Catalog's code. On the .NET side the first reflex is to put contracts into a NuGet package everybody references; in the Messaging and Event-Driven Architecture series post "Message Contracts and Schema Evolution: CloudEvents, AsyncAPI, and Versioning" we saw, with real output, how that package turns into a deployment dependency and that System.Text.Json ignores unknown fields by default. The same lesson applies to HTTP contracts: every consumer writes its own DTO (data transfer object).
A tolerant reader has a limit: it only protects the consumer against additive changes. However tolerant Notification is, once the author field is gone body.author returns undefined and the customer receives an email saying "Dune, undefined". That's the worst kind of silent breakage: no exception, just wrong data.
Expand-Contract: Splitting a Breaking Change into Steps
If turning author into authors is really necessary, instead of doing it in one deploy you split it into the three steps Danilo Sato described in 2014 as Parallel Change. Expand: the new field is added next to the old one and both are populated; old consumers notice nothing. Migrate: consumers move to the new field on their own schedule. Contract: once nobody reads the old field, it's removed. Catalog's response in the expand step looks like this:
// Expand: the old field stays, the new one is added next to it
public sealed record BookV2Expand(Guid Id, string Title, string Author, IReadOnlyList<AuthorDto> Authors, Money ListPrice);
Instead of one change this means at least three deploys and a migration lasting weeks; that's the price. In return there's never a moment where "everyone has to deploy the same night", which is independent deployability itself. Knowing when the contract step can be taken is a separate problem: you can't delete the old field without knowing who reads it. Pact will answer that question shortly.
When it's not a field but an entire endpoint that's being retired, there's a standard way to tell consumers at runtime. RFC (Request for Comments) 9745, published by the IETF (Internet Engineering Task Force) in March 2025, defines the Deprecation header; the Sunset header from RFC 8594 (2019) says after which date the resource will stop responding. Catalog marks its old price endpoint like this:
DateTimeOffset deprecatedAt = new(2026, 12, 1, 0, 0, 0, TimeSpan.Zero);
DateTimeOffset sunsetAt = new(2027, 3, 1, 0, 0, 0, TimeSpan.Zero);
app.MapGet("/api/books/{id:guid}/price", (Guid id, HttpResponse response) =>
{
// RFC 9745: Deprecation, structured field Date (@unix seconds); RFC 8594: Sunset, HTTP-date
response.Headers["Deprecation"] = $"@{deprecatedAt.ToUnixTimeSeconds()}";
response.Headers["Sunset"] = sunsetAt.ToString("R");
response.Headers.Link = "</docs/catalog/price-migration>; rel=\"deprecation\"";
return Results.Ok(new { amount = 14.90m, currency = "EUR" });
});
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 07 Nov 2026 23:45:00 GMT
Server: Kestrel
Transfer-Encoding: chunked
Deprecation: @1796083200
Sunset: Mon, 01 Mar 2027 00:00:00 GMT
Link: </docs/catalog/price-migration>; rel="deprecation"
{"amount":14.90,"currency":"EUR"}
RFC 9745 defines this header as a hint: the endpoint keeps working, the consumer has simply been warned. The Sunset date can't be earlier than the Deprecation date. The practical value is that consumers' HTTP clients log or emit a metric when they see this header, so who still calls the retiring endpoint is visible not only in Catalog's traffic but in the consumers' own dashboards.
Versioning: A Last Resort, Not a First Reflex
In contracts between services, versioning is for the cases expand-contract can't handle: a field's meaning changes, the response structure changes fundamentally, or old and new behaviour have to live side by side for months. We saw the mechanics of versioning by URL (Uniform Resource Locator), query string or header in the ASP.NET Core Web API series post "ASP.NET Core Web API Versioning: URL, Query String and Header with Asp.Versioning"; what matters here is the consequence at the service level.
The recommendation in the "Implementing Microservice Communication" chapter of Sam Newman's Building Microservices (2nd edition, 2021) is to offer the old and the new endpoint side by side inside the same service: /v1/books/{id} and /v2/books/{id} run in the same deployment on the same database, and v1 is most likely a thin adapter written on top of v2. The alternative is to deploy two different versions of Catalog separately and route traffic by version. That second option looks clean on paper but brings costs such as fixing a bug in two code bases, two versions interpreting the same data differently, and tracking which consumer goes to which version. Newman doesn't recommend it except for short transitions, and neither do I.
Versioning has a hidden cost too: every new version brings the responsibility of retiring the old one. If you don't know who uses v1, you can never switch v1 off, and a few years later Catalog carries five versions at once. So seeing a version number in the URL doesn't mean you're managing the contract; you need to know who the consumers are and what they rely on.
Event Contracts Follow the Same Rule
Contracts aren't only HTTP endpoints; the BookPriceChanged event Catalog publishes is a contract too, and its consumers (Ordering's local copy, the search index) deploy independently of Catalog. With events, feedback arrives even later than with HTTP: old-format messages wait in the queue, and a DLQ (dead-letter queue) can send a message from days ago back for reprocessing. That's why events bring in extra tools such as full compatibility (old and new consumers can read old and new data), versioned type names and a schema registry. We covered all of that, why adding an enum value breaks consumers, and the CloudEvents envelope in detail in the Messaging and Event-Driven Architecture series post "Message Contracts and Schema Evolution: CloudEvents, AsyncAPI, and Versioning"; I won't repeat it here. The principles in this post (tolerant reader, expand-contract, a change of meaning needing a new name) apply to events just the same. Pact has message pact support as well, but the example below uses HTTP.
Consumer-Driven Contract Tests: Notification and Catalog with Pact
The most common tool for putting consumers' expectations somewhere a machine can read is Pact. The flow has two steps. First the consumer, in its own test, runs against a mock server that imitates the provider and writes down what it expects; if the test passes, Pact records those expectations in a JSON file (the pact file). Then the provider, in its own pipeline, takes that file, sends each request in it to the real application and checks whether the response matches the expectation. Neither side has to start the other; no shared test environment is needed. Pact working across languages is valuable exactly for this scenario: the consumer is TypeScript, the provider is .NET.
Notification's consumer test, with Vitest:
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
import { describe, it, expect } from 'vitest';
import { getBookForEmail } from './catalogClient';
const { like } = MatchersV3;
const pact = new PactV4({ consumer: 'Notification', provider: 'Catalog', dir: './pacts' });
const duneId = '0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11';
describe('Catalog API', () => {
it('returns title and author for an order email', async () => {
await pact
.addInteraction()
.given('book Dune exists', { bookId: duneId })
.uponReceiving('a request for a book used in an order email')
.withRequest('GET', `/api/books/${duneId}`, (b) => b.headers({ Accept: 'application/json' }))
.willRespondWith(200, (b) => b.jsonBody({ title: like('Dune'), author: like('Frank Herbert') }))
.executeTest(async (mockServer) => {
const book = await getBookForEmail(mockServer.url, duneId);
expect(book).toEqual({ title: 'Dune', author: 'Frank Herbert' });
});
});
});
✓ src/catalogClient.pact.test.ts (1 test) 22ms
Test Files 1 passed (1)
Tests 1 passed (1)
Three details matter. like('Dune') doesn't require the value to be exactly "Dune", only of the same type (string); the contract doesn't break if the provider's test data returns a different book. The response body has only title and author; since Notification doesn't rely on listPrice, it doesn't write it into the contract either. given('book Dune exists') is a provider state: on the provider side a setup step with this name will run and create the test data. Once the test passes, pacts/Notification-Catalog.json is created; its response section, with lines compacted for readability, looks like this:
"response": {
"body": {
"content": { "author": "Frank Herbert", "title": "Dune" },
"contentType": "application/json",
"encoded": false
},
"matchingRules": {
"body": {
"$.author": { "combine": "AND", "matchers": [ { "match": "type" } ] },
"$.title": { "combine": "AND", "matchers": [ { "match": "type" } ] }
}
},
"status": 200
}
On the provider side, Catalog verifies this file against the real ASP.NET Core application with PactNet. I put the example into a single console application: the app starts on Kestrel and the verifier sends the request from the pact file to it:
// Provider state endpoint, enabled only in the test environment
app.MapPost("/provider-states", (ProviderStateRequest state) =>
{
if (state.State == "book Dune exists")
store.Seed(Guid.Parse(state.Params!["bookId"].GetString()!), "Dune", "Frank Herbert");
return Results.Ok();
});
app.Urls.Add(providerUri.ToString());
await app.StartAsync();
string pactFile = Path.Combine("..", "consumer", "pacts", "Notification-Catalog.json");
try
{
new PactVerifier("Catalog", new PactVerifierConfig())
.WithHttpEndpoint(providerUri)
.WithFileSource(new FileInfo(pactFile))
.WithProviderStateUrl(new Uri(providerUri, "/provider-states"))
.Verify();
Console.WriteLine($"[{mode}] verification passed");
}
catch (PactFailureException ex)
{
Console.WriteLine($"[{mode}] {ex.GetType().Name}: {ex.Message}");
}
For today's Catalog (v1) verification passes. Running the first change from Guess First, the version that removes author and adds the authors array (v2-breaking), gives this verifier output:
Verifying a pact between Notification and Catalog
a request for a book used in an order email (0s loading, 180ms verification)
Given book Dune exists
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json" (OK)
has a matching body (FAILED)
Failures:
1) Verifying a pact between Notification and Catalog Given book Dune exists - a request for a book used in an order email
1.1) has a matching body
$ -> Actual map is missing the following keys: author
...
There were 1 pact failures
[v2-breaking] PactVerificationFailedException: Pact verification failed
The failure shows up in Catalog's pipeline, before Catalog is deployed, and with Notification's name attached; the answer to "who uses this field?" is inside the test report. The version that makes the same change with the expand step of expand-contract (v2-expand: author stays, authors is added) passes: [v2-expand] verification passed. The third attempt is the more instructive one: the version that removes listPrice from the response entirely also passes against Notification's contract ([v1-no-price] verification passed), because Notification doesn't rely on the price. That's a correct result, but it's only a safe result if Ordering has published its own pact too. The strength and the weakness of consumer-driven contracts sit in the same place: the provider protects only the consumers that write contracts.
In a real setup the pact files come not from the file system but from a Pact Broker (the open-source Pact Broker or hosted PactFlow). The broker keeps a matrix of which consumer version was verified against which provider version and answers pact-broker can-i-deploy --pacticipant Catalog --version 2.4.0 --to-environment production with yes or no by looking at the consumer versions currently running in that environment; after a deployment, record-deployment tells the broker which version is where. Its place in the CI (continuous integration) pipeline is the topic of this series' "Microservices in Production" post.
Common Pitfalls
Mistaking contract tests for functional tests
Pact verifies not that Catalog returns the right book but that it returns it in the shape Notification expects. Business rules such as "return 409 when out of stock" stay in the provider's own tests. Writing every scenario into the consumer test moves the provider's internal behaviour into the consumer's repository and breaks two teams at once on every small change.
Expecting exact values instead of matchers
If you write title: 'Dune' instead of like, the provider's test data has to return exactly that book. Put only what really matters into the contract: type, format, required fields.
Saying "the test passed" when nobody publishes pacts
The listPrice attempt showed it: a consumer that doesn't write a contract is invisible. Adopting Pact isn't one team's decision but the system's; leaving a consumer out means no protection at all for that consumer.
Doing the expand step and forgetting the contract step
If author and authors live side by side for years, new consumers can't tell which one to use and the two fields may start showing different values. The contract step needs a date and an owner; the Deprecation header and the broker's list of consumers let you plan that date.
Solving every change with a new version
v2, v3 and v4 are easy to open and hard to close. Try to make the change compatible first; a new version is the last resort for contracts whose meaning changes.
A Small Experiment
★☆☆ In Notification's consumer test, write 'Frank Herbert' instead of like('Frank Herbert') for author and seed a different author in the provider state. What does the verifier say? Then go back to like and explain the difference.
★★☆ Write a second consumer test for Ordering: Ordering relies on title plus listPrice.amount and listPrice.currency. Give the provider both pact files and verify the v1-no-price version again. Whose name comes with the failure this time?
★★★ Plan the author → authors migration as three deploys: at each step, what does Catalog return, what do Notification and the BFF read, and which pacts pass? Which question do you need answered in the broker before you can take the contract step?
Contracts now change in a controlled way, but the Angular storefront's book detail page still goes to Catalog, Inventory and Ordering separately. In the next post we'll look at where those calls meet in one place, API Gateway and Backend for Frontend: what belongs at the edge and what doesn't.