Backward- and Forward-Compatibility Rules
What This Concept Is
Two independent compatibility questions attach to every change:
- Backward compatibility: can old clients keep working against the new server? If yes, the change is backward-compatible.
- Forward compatibility: can new clients still work against old servers during rollout? If yes, the change is forward-compatible.
Breaking a contract means either of these fails. Most teams focus on backward compatibility (old clients keep working) and treat forward compatibility (new clients tolerate old servers) as the deployment team's problem. Both matter.
The rules are mechanical once you agree on them. They are about the wire format, not the implementation. If the change is visible on the network, it is contract.
Why It Matters Here
Every change you ship either preserves compatibility or does not. Teams that do not classify changes end up shipping a breaking change by accident every few months, learning about it from a 3am Slack ping from an integration partner. A written set of rules turns "careful thinking" into a checklist a junior engineer can run in 60 seconds.
Concrete Example
Safe (backward-compatible) changes to a JSON response
- Adding an optional field:
Ordergets a newmetadataobject. Old clients ignore unknown fields. - Adding a new enum value for an output field if consumers tolerate it (see caveat below).
- Adding a new optional request field with a safe default when omitted.
- Adding a new endpoint. Old clients do not call it.
- Relaxing a validation: accepting strings of length 500 where you previously accepted 200.
- Loosening auth: allowing an operation to succeed without a previously required scope.
- Adding a new response status code to a documented list if it is not returned in previously covered cases.
Breaking changes
- Removing a field from a response. Clients that read it crash.
- Renaming a field. (Remove + add.)
- Changing a field's type.
int->string,string->object. - Changing a field's semantics.
amountpreviously meant dollars; now it means cents. - Making a previously optional field required in a request.
- Making a previously returned field optional. Old clients may assume it is always present.
- Adding a new enum value for an input field - the client already validates and will reject it. Adding one for an output is also risky if clients use exhaustive
switch. - Tightening a validation: rejecting inputs you previously accepted.
- Removing or renaming an endpoint.
- Changing pagination tokens' lifetime/encoding in a way that invalidates in-flight client state.
- Changing status code for a given outcome (previously
200, now204; previously404, now410). - Changing error codes for a given failure class.
Subtle cases worth writing down
- Changing the sort order of a list endpoint. Clients that iterate may see duplicates or misses during the change. Add the new order with a flag first.
- Changing from
nullto absent for a default value (owner: nullvsownermissing). These parse differently in strict clients. Pick one in the contract and stick with it. - Changing documentation without changing the wire. If consumers depended on the undocumented behavior (and they always do), the change is still breaking in practice. Geewax calls this out as a real risk.
- Increasing rate limits. Safe for almost all consumers - but any consumer that paced themselves against the old limit may get surprise throttling if you decrease later. Decreases are effectively breaking.
Wire example: a legal vs illegal change
Before (v1):
{
"id": "ord_1",
"total": 4599,
"currency": "USD",
"status": "paid"
}
Safe (v1.1):
{
"id": "ord_1",
"total": 4599,
"currency": "USD",
"status": "paid",
"tax_breakdown": { "vat_minor": 600, "other_minor": 0 }
}
Breaking:
{
"id": "ord_1",
"total": { "amount_minor": 4599, "currency": "USD" },
"status": "paid"
}
total went from integer to object and currency moved inside it. Both old-client reads break.
Common Confusion / Misconception
"Adding a field is always safe." Not if strict parsers are in use (Protobuf with unknown-field rejection, some JSON-schema validators in strict mode, Avro with strict compatibility mode). Test against the actual clients you have.
"Changing docs to match reality is not a change." Consumers sometimes build against reality, not docs. A "doc correction" that implies different behavior can still break them. Prefer behaviour-preserving changes and clearly-marked deprecations.
"We only need backward compatibility." Forward compatibility matters during deploys: a new client might talk to a not-yet-upgraded server during a rolling rollout. Requiring the new client to tolerate the old wire format is how you avoid a 30-minute window where nothing works.
"Private APIs don't need compatibility rules." They do, because "private" is usually "called by five other teams who treat it as forever-stable." If you coordinate with those teams per release you can move faster; if you do not, the same rules apply.
"Versioning will save us from compatibility rules." Versioning lets you schedule breaking changes; it does not eliminate compatibility thinking. Within a version, compatibility rules are absolute.
How To Use It
Run this checklist on every proposed contract change:
- Is any field removed, renamed, retyped, or repurposed? -> breaking.
- Is any previously optional input now required? -> breaking.
- Is any previously required output now optional or absent? -> breaking.
- Is a validation tightened (shorter max length, narrower pattern, new required)? -> breaking.
- Does an endpoint's status code change for a given outcome? -> breaking.
- Does an error code change for a given failure? -> breaking.
- Does any enum change in a way that clients built with exhaustive matching would reject? -> breaking.
- Does any behavior change that clients could observe? (ordering, idempotency semantics, retries) -> breaking.
If the answer to all eight is "no," the change is backward-compatible and can ship within the same version.
Forward-compatibility additions:
- Will new clients emit a field that old servers will reject? (Usually safe if servers ignore unknown - check.)
- Will new clients set a header that old servers interpret differently? (Rare but real -
Accept: application/vnd.x.v2+jsonmust degrade to the v1 response on old servers, or deploys will break.)
Check Yourself
- Adding an enum value to an output field: safe or breaking? Explain the two client behaviors that make it subtle.
- A field was always returned as a non-null integer. You want to let it be null when data is missing. Breaking?
- A validation was "at least 1 character, any character." You tighten it to "at least 1, letters only." Breaking? Who pays?
Mini Drill or Application
Take a toy API version v1 and a proposed v1.1 diff (invent one). For each change:
- Classify it as backward-compatible, forward-compatible, both, or breaking.
- If breaking, propose an alternate design that delivers the value without breaking (new field, opt-in flag, feature header).
- For each change you classify as safe, identify one real client-parser setting that would make it unsafe, and note the mitigation.
Target at least 10 change types in the exercise.