The Runtime Theory
API Design

Designing APIs That Age Well: Compatibility, Additive Changes, and Deprecation

How to design REST APIs that age well: additive changes, removal breakage, compatibility windows, and why deprecation is a migration project, not a release note.

The Runtime Theory Team4 min read#api-design#versioning#backwards-compatibility#deprecation#contracts
On this page

Every API starts with the same assumption: that you control both sides of the contract. You don't. The moment a third party consumes your endpoint, every field is a promise and every removal is a negotiation. The APIs that age well are not the ones with clever versioning schemes — they are the ones that treat compatibility as a constraint on the server, not a courtesy to the client. This article is about the mechanical rules that keep a contract intact while the world around it changes.

The one rule that governs everything: additive changes are safe, everything else is not

Almost every compatibility rule in existence falls out of a single fact about clients: a client is compiled or configured against a fixed expectation of the schema, and it only breaks when the server violates that expectation. Adding a field, a status code, or an endpoint never violates it — the client simply ignores what it doesn't know. Removing, renaming, reordering, or retyping anything does.

json
{
  "id": "ord_48211",
  "amount": 1950,
  "currency": "usd"
}

This is safe to send to a client that expects only id. Adding "status": "fulfilled" is also safe — the old client ignores it, and new clients use it. But renaming amount to total_amount breaks every client that was compiled against amount. The rule of thumb: the server may always grow the contract; it may never shrink or reshape it.

Why removing a field breaks clients that "obviously" don't use it

The tempting move is to drop a field nobody uses. The problem is you don't know who uses it. JSON parsers in most languages are lenient — a client can deserialize the response into a typed struct and silently drop unknown fields, or a client can send a request payload with extra fields the server ignores. Either way, the usage is invisible to you.

The failure modes are worse than "field disappears." A client that does payload.amount * 0.25 in JavaScript against a field that is now undefined produces NaN, which then gets serialized as null downstream — the error surfaces three services away from your API. A typed client throws a hard deserialization error. Both are bugs you will hear about from users, not from logs.

python
# Client code written against the old contract
response = client.get("/orders/48211")
discount = response["amount"] * 0.25   # KeyError the day you remove "amount"

If you must remove something, the sequence is: keep the field but stop populating it meaningfully → mark it deprecated in the schema → measure real traffic for a deprecation window (typically 12-24 months) → remove it only after the traffic is zero. "Nobody uses it" is a hypothesis; traffic data is a fact.

Compatibility windows and the deprecation lifecycle

A deprecation is not a release note — it is a migration project executed by someone else. Practically, that means a deprecated API must:

  • Keep working, with identical semantics, during the window.
  • Emit a signal that is visible but not loud enough to crash: a Deprecation response header, a deprecated: true flag in the schema, and an x-deprecation header with a retirement date.
  • Document the replacement endpoint and a migration path in the same document where the deprecation is announced.
http
HTTP/2 200 OK
Deprecation: true
Sunset: Fri, 18 Sep 2026 09:00:00 GMT
Link: </v2/orders>; rel="successor-version"

The Sunset header is the contract's legal text: it states when the old behavior will stop. Promising a sunset date and holding it — even when the traffic looks low — is what makes future deprecations believable.

Additive change patterns: optional fields, alternatives, and expansion

The toolkits for growth without breakage are simple, and worth enumerating:

  • New fields are always optional in requests, and added as null-tolerant or omitted in responses. If a new field is required for new features, it must still have a sensible default for old clients.
  • New endpoints over new versions. Adding POST /v1/orders/{id}/cancel does not require a v2. Adding behavior to an existing endpoint usually does.
  • Enumerations only grow. An enum value can be added; a client that doesn't know it must be able to treat it as "other." If the server cannot tolerate unknown enum values in requests, it will reject valid new clients during rollouts — the classic "version skew during deploy" bug.
  • Status codes are ranges, not atoms. A 4xx that used to return 400 may now return 409; clients must treat 4xx as a class, and servers must document the individual codes as additively introduced.

When versioning is actually the answer

Versioned endpoints (/v1, /v2) buy you the right to break — they do not make breaking free. A v2 that ships without migration tooling, compatibility shims, or a sunset plan for v1 is just a more expensive way to break your clients. Version aggressively only when the change reshapes the core entity model (rename, restructure, retype); prefer additive evolution everywhere else. And once you have versions, honor the oldest one you still promise to support — an API that breaks its promises has no contract worth versioning.