Backward Compatibility & Versioning
As soon as something depends on your interface — an API consumer, an old app version, a queued message, a stored record — you have made a promise about its shape. Break that promise and you break them, often silently and far away. Evolve interfaces additively, version on purpose, and never strand a consumer in the middle of a change.
Compatibility is not only about public APIs. The same discipline applies to database schemas during a rolling deploy, the format of messages on a queue, persisted documents, and events other services consume. In every case there is a period where producers and consumers run different versions at once, and the contract must keep working across that gap.
The reliable strategy is expand/contract. Add the new thing, support old and new together while everyone migrates, then remove the old thing in a later step. Never change and remove in one move. Combine this with tolerant readers (ignore unknown fields, do not assume optional ones) and clear versioning when a real break is unavoidable. This lets the system evolve continuously without downtime or stranded consumers.
Evolve without breaking
- DoMake changes additive by default: new optional fields, new endpoints. Then existing consumers keep working unchanged.
- DoUse expand/contract for anything breaking. Add the new shape, migrate readers and writers, then remove the old shape in a later release (see Schema Versioning).
- DoBe a tolerant reader and a careful writer. Ignore unknown fields you receive, and do not drop or reuse fields others may still depend on.
- AlwaysKeep each step backward-compatible during rolling deploys, so the old and new versions running side by side both work.
- ConsiderTreating queue messages, events, and persisted documents as versioned contracts too. Old messages and records must still deserialise.
- NeverShip a breaking change to a contract others depend on without a version, a migration path, and notice. Silent breaks strand consumers.
Version deliberately when you must break
- DoWhen a break is truly necessary, add a new version alongside the old, and support both through a defined deprecation window.
- DoAnnounce deprecations clearly with timelines, and track who still uses the old version before you remove it.
- DoNever reuse an existing field or change its meaning or type in place. Add a new one instead. Reused fields are the hardest breaks to spot.
- ConsiderContract tests against consumers, so an accidental breaking change fails CI instead of production.
- Do notRename or remove fields, tighten validation, or change status codes or error shapes without treating it as a breaking change.
- AvoidLeaving old versions running forever. Deprecate and retire on a schedule, or too many versions become their own maintenance burden.
// v1: "status" was a string "active"/"closed"
// v2: changed "status" to a number 0/1/2 in the same field
Every existing consumer that read the string now gets a number and breaks. Old stored records no longer match the new readers. This is a silent, system-wide break that looks like a small change.
// add a new field, keep the old one working
{ "status": "active", "statusCode": 0 }
// once all consumers read statusCode (and old records are backfilled), drop status
Old and new consumers both work throughout. Nothing is stranded. The old field is removed only when nothing depends on it.
Self-review checklist
- AskWho or what depends on this shape — consumers, old app versions, queued messages, stored records?
- AskIs this change additive, or does it break an existing promise?
- AskDuring a rolling deploy, will the old and new versions both keep working?
- AskIf I must break it, is there a version and a migration path, not a silent change?