Backward Compatibility as a Constraint
Old clients, stored data, in-flight events, published APIs, plugins and databases all constrain what today's change may do. Compatibility is not a property of an API; it is a constraint on every edit.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Who is still running the old version of this, and what does that forbid me from doing today?
Rename the status field on orders to fulfilment_status and add two new values, because status now means three different things in three different places.
Rename the field, update every consumer in the repository, and ship it in one release. The field name is an implementation detail and it is wrong; fixing it is a rename.
The mobile app is not in the repository, and it does not update because you shipped. For eleven months there are clients reading a field that no longer exists.
- The mobile app is not in the repository, and it does not update because you shipped. For eleven months there are clients reading a field that no longer exists.
- Four years of stored orders still have the old key. Reading them with the new code either fails or silently produces a null that is treated as a legitimate status.
- Replayed events carry the old field, so disaster recovery — the thing you never test until you need it — produces a stream the new consumer cannot parse.
- The two new status values break consumers with exhaustive matching on the old set, and the partner cannot ship a fix inside the quarter (Enum Evolution: The New Value That Broke Old Clients in API Design is the same problem, one layer out).
- As requirements arrive, the temptation is to special-case: a translation here, a default there. Six months later nobody can say which representation is authoritative (Duplicate Knowledge).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- A mobile app in the wild reads
status; the slowest 5% of users update after about eleven months. - Four years of orders are stored with the old field name and the old value set.
- An event stream carries order events that downstream consumers replay from the beginning during recovery.
- Two internal services and one partner integration consume the field, and the partner takes a quarter to make any change.
- A client running the version that was current yesterday must keep working today. That is the whole constraint, stated once.
- Stored data written by any still-supported version must remain readable and correctly interpreted.
- A replayed event from four years ago must still be interpretable by today's consumer, or replay is not a recovery mechanism (Replay from the Log in Data Engineering).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Someone owns the list of everything still reading this: clients, stored data, events, jobs, partners, plugins, and the analyst's dashboard. Compatibility cannot be reasoned about without that inventory.
- Someone owns the support window per consumer class — how long an old mobile version is supported, how far back events are replayed — because that is what turns "compatible" into a finite obligation (Deprecation).
- A single translation point owns mapping between representations. Scattered translations are how two representations become four (Anti-Corruption Layer).
- Compatibility obligations start at the boundary you do not control: anything you can atomically redeploy is not a compatibility problem, it is a refactor.
- Persisted data is a boundary with your past self, and it is the one people forget because it does not look like an interface (State Ownership).
- Events are a boundary with your past *and* future self, because they are read forward by new consumers and backward during replay.
Six kinds of old reader, and what each forbids
The word "backward compatibility" collapses six quite different obligations with different durations. Separating them is what turns a vague anxiety into a finite, datable list of constraints on today's change.
Note the durations. They range from minutes to years, and the longest one sets how long your compatibility code must live.
- The obligation is the *maximum* over all six, not the average, and it is usually set by the one nobody listed.
- Databases appear twice: as stored data with a retention window, and as a schema other teams query directly, which is a published API nobody agreed to publish (Schema Leakage in Backend).
- Write the window down as a date. "Until clients upgrade" is not a constraint anyone can design against.
- Data Engineering owns the machinery built for exactly this — schema registries, compatibility modes, producer/consumer evolution (Schema Evolution, Breaking Schema Changes, Forward Compatibility). Here it is one of six obligations rather than the subject.
| Old reader | Window | What it forbids today |
|---|---|---|
| In-flight code — the half of the fleet not yet redeployed | Minutes to hours | Writing a shape the previous release cannot read. Deploy the tolerant reader one release before the new writer (Version Coexistence: N and N+1, in Both Directions in DevOps). |
| Stored data — rows written by every past version | Retention period: often years | Removing a representation the code must still interpret. This is the obligation people forget, because a table does not look like a consumer. |
| Events and messages — queued, and replayed during recovery | Retention, plus replay depth | Changing a field's meaning. A replayed four-year-old event must produce the same interpretation it did then (Event-Driven Architecture in Architecture). |
| Published APIs — internal and external callers | Their upgrade cycle, not yours | Removing or narrowing anything. API Design owns the evolution mechanics; here it is a constraint on the code behind it. |
| Shipped clients — mobile apps, desktop, embedded, SDKs | Months to years; the tail is long | Anything at all that is not additive, for as long as the tail lasts. You cannot deploy a fix to these. |
| Plugins and extensions — third-party code against your interfaces | Effectively indefinite | Changing any signature, type or lifecycle hook. A plugin API is the strictest compatibility surface most codebases have (Plugin Architecture). |
What "additive" actually looks like in code
The rename is the clearest case because it looks so harmless. In a repository where every reader is visible, it is a rename; across a boundary with old readers, it is a three-release sequence and a scheduled deletion.
The second version is uglier, and that ugliness is temporary by design — it has a removal date. The failure mode is not the ugliness, it is the removal that never happens.
// v2 writes and reads only the new name
type Order = { fulfilment_status: FulfilmentStatus }
// 4 years of stored rows have `status`
// mobile clients read `status` for ~11 months
// replayed events carry `status`
// -> null statuses, treated as "unknown", quietly// R1: read either, write both. Ships to everyone first.
function statusOf(o: StoredOrder): FulfilmentStatus {
return o.fulfilment_status ?? o.status // tolerate old
}
function write(o: Order) {
return { ...o, status: o.fulfilment_status, // for old readers
fulfilment_status: o.fulfilment_status }
}
// R2: backfill old rows; internal consumers move to the new name.
// R3 (dated, owned): stop writing `status`, delete the fallback.
// Guard: no client older than 2026-07 still calling.The first version is correct in a world where you can redeploy every reader at once, and that world does not include stored rows, queued events or shipped clients. The second is not more careful in spirit — it is the only version that keeps the invariant "a client that worked yesterday works today" true, which is the thing being protected. Note that R1 must reach production *before* anything writes the new name: the tolerant reader always ships first (Expand and Contract).
Where compatibility quietly stops holding
Most compatibility incidents are not a missing shim. They are a shim that covered reads but not writes, or a removal executed against an inventory that was out of date, or two representations that drifted apart.
The recurring cause: compatibility was treated as a property established once, rather than as an invariant that every subsequent change must preserve.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Old client writes after the new code stopped dual-writing | Rows with the new field null; a report undercounts by 4% | Compatibility was implemented for readers only; writers were not inventoried | Treat every direction as a pairing: old reads new, new reads old, old writes, new writes |
| Shim removed after "checking nothing uses it" | Support tickets from users on an 11-month-old app build | The inventory covered the repository, not the wild | Verify removal against production telemetry — client versions actually calling — not against a code search (Deprecation) |
| A later change updates one representation | Two fields disagree; whichever is read decides the answer | Dual representation with no single owner and no reconciliation check | Derive one from the other at a single point, never maintain both independently (Duplicate Knowledge) |
| New enum value reaches an old consumer | Partner integration throws on an unrecognised value | The enum was extensible in your code and closed in theirs | Define unknown-value behaviour in the contract before you need it; if you cannot, the enum is not extensible (Enum Evolution: The New Value That Broke Old Clients in API Design) |
| Disaster recovery replays four-year-old events | Consumer crashes on a field that no longer exists | Replay depth was never counted as a compatibility window | Include the replay horizon in the support window, and test a replay from the oldest retained offset |
How to build it
Most important first.
- Inventory the readers first. Every compatibility decision is downstream of knowing who is out there and for how long.
- Add, never change or remove, while old readers exist. New field alongside old, new value alongside old, both populated during the overlap (Expand and Contract).
- Write both representations and read whichever is present, for as long as the support window requires — that window is the design parameter, and it must be a date.
- Make new values additive-safe: consumers must have a defined behaviour for a value they do not recognise, and if they do not, the enum cannot be extended safely (An Error Taxonomy That Survives Contact).
- Translate at one boundary, on the way in and on the way out, so the core of the system knows exactly one representation (Boundary Adapters).
- Schedule the removal with a date and an owner, and verify no reader remains before executing it. A compatibility shim with no removal date is permanent.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- This change: three releases instead of one, plus dual-writing code, plus a scheduled removal that someone must actually do. Roughly a week rather than an hour.
- The next change to the same field is cheaper only if the removal happened. If it did not, every future change must maintain both representations, and the cost is permanent and compounding (Interest: Why Debt Compounds).
- The cost of getting it wrong is asymmetric and worth stating: a broken mobile client is a support incident measured in weeks, because you cannot deploy a fix to it.
- What does not get cheaper: adding a *semantic* change to an existing field. Compatibility techniques protect shape, not meaning, and a field whose meaning changed while its name and type stayed the same is undetectable by any of them.
- Additive-only change means the data model accumulates fields that exist for historical reasons, and new engineers cannot tell which are current without reading migration history.
- Dual representations double the surface a bug can hide in, and the second one is exercised only by traffic you cannot easily generate.
- Honouring an eleven-month client window slows down every change to that surface for eleven months. Shortening the window is a legitimate product decision with a real cost to a real minority of users, and engineering should present it as such rather than absorbing it silently.
What can go wrong
- Compatibility is maintained for readers but not for *writers*: an old client writes the old shape after the new code stopped populating it, and the row is silently incomplete.
- The shim outlives everyone's memory of why it exists, and a later engineer removes it because nothing seems to use it — except the 5% of mobile users on an eleven-month-old build.
- Both representations are written but they drift, because a later change updated one and not the other. Two sources of truth with no reconciliation is worse than one wrong one.
- The support window is never written down, so "how long must we keep this?" is answered by whoever is most risk-averse, and the answer is always "forever".
- The mitigation fails on its own terms: a translation layer accumulates business rules and becomes a component that must itself be migrated.
- Your change now depends on consumers you do not control, which inverts the usual direction: their upgrade schedule constrains your design (Dependency Direction).
- It depends on your data retention: the older the data you must read, the more representations the code must understand.
- It depends on the deploy topology — a system that can be atomically replaced has almost none of these obligations, which is why this constraint feels invented to teams that have only ever shipped a monolith with a window.
- "This is an API concern." It is the constraint on every change to running code. The stored row, the queued message, the plugin and the cached value are all old readers, and none of them is an API (Versioned Interfaces).
- "Adding a field is always safe." Not if a consumer validates strictly and rejects unknown fields, and not if the new field changes the meaning of an existing one. Additive is safer, not safe.
- "We control all the clients, so this does not apply." You do not control the rows you wrote last year, the events in the queue, or the instance mid-rolling-deploy. The window may be minutes instead of months, but the pairings are the same (Designing the Migration).
- "Versioning solves this." Versioning is one way to pay the cost, and it moves the obligation rather than removing it — you now support N versions instead of two representations (Versioned Interfaces).
Testing it, and how it ages
- Run the previous release's test suite against the new code and the new data. This is the single most effective compatibility test and it is almost never done.
- Round-trip stored data from every still-supported representation, using real archived rows rather than synthesised ones.
- Replay a genuinely old event through the current consumer, as a test, so that recovery is verified rather than assumed.
- Contract tests shared with the consumers you control, and a recorded fixture from the ones you do not (Contract Tests).
- Every compatibility obligation has a natural end — when the last old client, row or event is gone — and the design should name it. Obligations without ends accumulate until the codebase is mostly translation.
- As the number of supported representations grows, the cost of every change grows with it. Two is manageable; four means the shape of the data is now a historical record rather than a design.
- Long-lived systems eventually pay to normalise history: a one-off migration of old rows into the current shape, precisely so that the code can stop knowing about the old one (Data Migration).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALWherever something written by an older version is read by a newer one, or the reverse, the obligation exists; only its duration changes between a rolling deploy and a shipped mobile app.
- LIFETIME-SPECIFICFor an internal tool redeployed atomically with all its consumers, the compatibility window is zero and every technique here is pure overhead. For data with a seven-year retention requirement, the window outlasts the team, and the code must be able to read shapes written by people who have left. Same principle, incomparable cost.
- CONTESTEDThe strongest opposing view: unbounded backward compatibility is how systems become unchangeable, and a discipline of short, loudly-announced breaking changes with hard cutoff dates keeps a system simple at the cost of some consumer pain. Teams that have shipped strict deprecation policies argue that permanent compatibility silently transfers cost from the few consumers who will not upgrade onto every future change for everyone. The counter is that this only works when you have leverage over your consumers — it is a reasonable internal policy and a poor way to treat a paying partner.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — running the previous release's suite against the current build is a CI arrangement, and it is the highest-yield compatibility test most teams are not running.