Ch. 10 · Microservices

Evolving Microservice APIs and Events Without Breaking Consumers

Which API and event changes are safe, why a rename breaks even tolerant readers, and how expand-and-contract lets a producer ship alone.

~6 min readintermediateupdated Oct 7, 2026

The promise of microservices is independent deployment: the order team ships on Tuesday without coordinating with billing. That only works if the order service can change its API and events without breaking the services that consume them. The contract is the set of fields, types and meanings that consumers rely on. Contract evolution is the discipline of changing it without a synchronised release.

Interviewers usually probe this with a concrete change: “you need to rename a field” or “you need to change amount from euros to cents”. The answer they’re listening for is a sequence of deployments, not a single one.

Three versions of one event

Here’s an OrderPlaced event at three points in its life:

const v1 = { orderId: 'o-991', amount: 4250, status: 'PLACED' };
const v2 = { orderId: 'o-991', amount: 4250, status: 'PLACED', currency: 'EUR' };          // added a field
const v3 = { orderId: 'o-991', amountCents: 4250, status: 'PLACED', currency: 'EUR' };     // renamed a field
JavaScript

And two ways a consumer might read it. The strict reader validates the whole payload and refuses anything unexpected. The tolerant reader takes only the fields it needs and ignores the rest:

function strictReader(e) {
  const known = new Set(['orderId', 'amount', 'status']);
  for (const k of Object.keys(e)) if (!known.has(k)) throw new Error(`unexpected field "${k}"`);
  return `charge ${e.amount}`;
}
function tolerantReader(e) {
  if (typeof e.amount !== 'number') throw new Error(`missing required field "amount"`);
  return `charge ${e.amount}`;
}
JavaScript

Feeding every version to both readers in Node 22:

v1 -> strictReader   charge 4250
v1 -> tolerantReader charge 4250
v2 -> strictReader   ERROR unexpected field "currency"
v2 -> tolerantReader charge 4250
v3 -> strictReader   ERROR unexpected field "amountCents"
v3 -> tolerantReader ERROR missing required field "amount"
Text

Two lessons from six lines.

Adding a field is only “safe” if consumers are tolerant. The producer did nothing wrong in v2, but the strict consumer fell over. This is common in practice: a JSON schema with additionalProperties: false, a Jackson ObjectMapper with FAIL_ON_UNKNOWN_PROPERTIES enabled, or a generated client that validates responses. (Spring Boot’s auto-configured ObjectMapper disables that Jackson feature, which is one reason Spring consumers tolerate additions by default; a hand-built new ObjectMapper() does not.) The rule for consumers is the tolerant reader pattern: read what you need, ignore what you don’t, and don’t validate the parts of a message you don’t use.

A rename breaks everyone, tolerant or not. From the consumer’s point of view, v3 removed amount and added an unrelated field. Tolerance can’t help, because the field it needs is gone. If the tolerant reader had used e.amount ?? 0 instead of checking the type, it would have quietly charged zero, which is worse than an error.

What counts as a breaking change

A useful mental list, for both HTTP APIs and events:

Change Breaking?
Add an optional field to a response or event No, if consumers are tolerant
Add an optional field to a request No, if the server has a default
Add a required request field Yes, old clients don’t send it
Remove or rename a field Yes
Change a field’s type ("4250" to 4250) Yes
Add an enum value Often yes: consumers with an exhaustive switch or a strict enum parser fail
Change a field’s meaning (euros to cents, same name) Yes, and no schema tool will detect it
Tighten validation (max length 200 to 50) Yes for requests

The meaning change deserves emphasis. If amount silently changes from euros to cents, every schema check passes, every consumer parses successfully, and billing charges 4,250 euros instead of 42.50. The safe way to change units is to introduce a new field with the unit in its name (amountCents) and treat it like a rename.

Events are less forgiving than HTTP in one respect: they’re stored. A Kafka topic with 30 days of retention, or an event-sourced store kept forever, contains old shapes. A consumer that’s rebuilt or replays from the start must read v1, v2 and v3 events. HTTP responses disappear after they’re read; events don’t.

Expand and contract: renaming without a coordinated release

The rename from amount to amountCents is done in steps, each independently deployable:

  1. Expand. The producer publishes both fields with the same value: amount: 4250, amountCents: 4250. Nothing breaks, since old consumers still find amount (tolerant ones ignore the extra field).
  2. Migrate. Each consumer switches to reading amountCents, falling back to amount for old events still on the topic. These deploys happen whenever each team is ready.
  3. Verify. Confirm that no consumer reads amount anymore. This is the hard part, and it’s where contract tests or usage telemetry earn their keep.
  4. Contract. The producer stops publishing amount. For events, the old field may still exist in retained data, so consumers keep the fallback until that data has aged out.

The same shape applies to database columns, config keys and API parameters. It’s slower than a single deploy, and that’s the price of not needing a synchronised one.

For HTTP APIs, a new major version (/v2/orders) is the alternative when the changes are too large for expand-and-contract. It’s heavier: you run two versions and must eventually retire v1. Use it for genuine redesigns, not for a single rename.

Knowing who you’ll break: consumer-driven contracts

Step 3 asks a question the producer usually can’t answer from its own code: who reads amount? Consumer-driven contracts flip the knowledge around. Each consumer publishes a contract describing the fields and types it actually depends on, and the producer’s CI verifies every change against all of them.

A toy version of the check:

const contracts = {
  billing:   { orderId: 'string', amount: 'number' },
  analytics: { orderId: 'string', status: 'string' },
};
function verifyProducer(sample) {
  for (const [consumer, fields] of Object.entries(contracts)) {
    const broken = Object.entries(fields).filter(([f, t]) => typeof sample[f] !== t).map(([f]) => f);
    console.log(`  ${consumer.padEnd(9)} ${broken.length ? 'BROKEN: ' + broken.join(', ') : 'ok'}`);
  }
}
JavaScript
producer CI check against v3:
  billing   BROKEN: amount
  analytics ok
producer CI check against expand step:
  billing   ok
  analytics ok
Text

The producer learns, before merging, that publishing v3 directly would break billing but not analytics, and that the expand step breaks nobody. Real tools (Pact, or Spring Cloud Contract in the Spring world) do the same thing with request/response examples and generated tests, plus a broker that stores which consumer versions are deployed where.

Contract tests aren’t end-to-end tests. They check the shape of interactions between two services in isolation, which is fast and doesn’t need a shared environment. They also only cover consumers who wrote contracts, so an unknown consumer reading your events is still a risk. That’s an argument for knowing your consumers (topic ACLs, a schema registry with compatibility rules) rather than letting anyone subscribe silently.

Interview practice: changing a status field

“The shipping service publishes ShipmentUpdated events with status as a free-text string: "in transit", "delivered". You want to switch to an enum (IN_TRANSIT, DELIVERED) and add a new state, HELD_AT_CUSTOMS. Five teams consume the event. How do you roll this out?”

Answer

This is two breaking changes (changed values for an existing field, and a new state that consumers don’t handle), so I wouldn’t change status in place. I’d add a new field, statusCode, with the enum values, and keep publishing the old status string alongside it. For HELD_AT_CUSTOMS, the old status needs some value existing consumers understand, probably "in transit", since that’s what it means to them.

Consumers then migrate to statusCode one by one. Each must handle unknown enum values gracefully (a default branch that logs and treats it as “in progress”), because there will be future states too. I’d use contract tests or at least a list of consumers with their deployment status to know when everyone has moved. Only then would the producer stop publishing status, and consumers that replay old events keep a mapping from the old strings. I’d also register the event schema with compatibility checks so a future removal of statusCode gets rejected in CI rather than discovered in production.

Further reading

More in Microservices

esc