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 fieldAnd 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}`;
}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"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:
- Expand. The producer publishes both fields with the same value:
amount: 4250, amountCents: 4250. Nothing breaks, since old consumers still findamount(tolerant ones ignore the extra field). - Migrate. Each consumer switches to reading
amountCents, falling back toamountfor old events still on the topic. These deploys happen whenever each team is ready. - Verify. Confirm that no consumer reads
amountanymore. This is the hard part, and it’s where contract tests or usage telemetry earn their keep. - 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'}`);
}
}producer CI check against v3:
billing BROKEN: amount
analytics ok
producer CI check against expand step:
billing ok
analytics okThe 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
- Microservices interview questions
- Microservices MCQs
- API versioning
- Schema registries
- Martin Fowler’s site: Consumer-Driven Contracts