Back to Blog
Backend13 min readJun 2026

API Design: Evolution & Versioning

How to change an API without breaking everyone who depends on it: backward-compatible changes, versioning strategies, deprecation policy, and contract testing.

BackendAPIVersioningCompatibility
SB

Sri Balaji

Founder · TheSimplifiedTech

On this page

The day one field rename took down every phone

It was a one-line change. A field called user_name had always bothered the team, it should have been username, one word, like the rest of the schema. So someone renamed it. The pull request was tiny, the tests were green, the deploy was clean. Twenty minutes later, support lit up: the mobile app was showing blank profiles for every user on every version of the app, all at once.

Nothing was down. The server returned 200. The database was fine. The problem was that three years of shipped mobile binaries were all reading response.user_name, and that key had simply vanished. You cannot push a hotfix to a phone in the App Store review queue. The fix was to redeploy the old field, and then live with user_name forever.

That is the whole discipline of API evolution in one story: the moment someone depends on your shape, the shape is a promise. This article is about keeping that promise while still letting your API grow.

Who this is for

Backend engineers who own an API that real clients already consume, mobile apps, partner integrations, other internal services. If you have ever been afraid to change a response because you do not know who reads it, this is for you. We build on [REST API Design](/blog/rest-api-design) and assume you know what a JSON response looks like.

The one rule: you can add, you cannot take away

You can add to an API. You cannot take away from it. Every field you remove and every name you change is a promise broken to someone who already trusted the old shape.

This is sometimes called the robustness principle applied to APIs, and it has a clean asymmetry. Adding a new optional field, a new endpoint, or a new enum value that old clients can ignore is safe, nobody was depending on the absence of a thing. Removing a field, renaming it, making an optional field required, or changing its type is unsafe, somebody, somewhere, is reading exactly what you just changed.

A contract both parties already signedYour published API response shape
Adding a new clause both sides agree to laterAdding a new optional field
Quietly deleting a clause the other party relies onRemoving or renaming a field
A signed amendment with a start dateA new API version, announced and dated
A notice period before the old terms expireA deprecation window before sunset
An API is a signed contract, not a private implementation detail.

The mental shift: your API is not your code. Your code is yours to refactor freely. Your API is a contract you co-signed with every client. You do not get to edit a signed contract unilaterally, you either add an amendment everyone accepts, or you issue a new version and give people time to move.

The lifecycle: v1 and v2 live side by side

When a change truly cannot be made additively, you do not flip a switch. You run the old and new shapes in parallel, announce a deprecation, and only then sunset the old one. Here is the shape of that lifecycle.

requestdeprecatedpreferredtrack callers
Clients

mobile + partners

API Gateway

routes by version

v1 handler

old shape

v2 handler

new shape

Domain logic

shared core

Usage metrics

who still calls v1?

v1 and v2 served together, then v1 is deprecated, then sunset, clients migrate in the gap.

The key insight is the shared Domain logic node. Both versions call the same core; they differ only at the edge, in how they shape the response. A version is a translation layer, not a fork of your business logic. The Usage metrics node is what tells you when it is actually safe to sunset v1, never sunset on a guess.

  1. 1

    Ship v2 alongside v1

    Add the new shape behind a new version. v1 keeps working untouched. Both handlers call the same domain core so behaviour stays identical.

  2. 2

    Announce the deprecation

    Mark v1 deprecated in the docs, return a `Deprecation` and `Sunset` header on v1 responses, and tell known clients directly. Give a real date.

  3. 3

    Measure who still calls v1

    Instrument the v1 handler. You cannot sunset what you cannot see. Watch the call count trend toward zero, and identify the stragglers by API key.

  4. 4

    Nudge the stragglers

    As the date nears, email the remaining callers, raise the log level, optionally inject brief artificial latency or warning bodies. Do not surprise anyone.

  5. 5

    Sunset v1

    Once usage is at (or near) zero and the date has passed, return 410 Gone for v1, then delete the handler. The contract has ended on agreed terms.

What is safe vs what breaks

Most of the day-to-day skill is simply knowing which bucket a change falls into. Additive changes ship any time, no version bump. Breaking changes force the lifecycle above. Memorize this table and most of the fear goes away.

ChangeAdditive / safeBreaking
Add a new optional field to a responseYes, old clients ignore it
Add a new endpoint or methodYes, nobody called it before
Add a new optional request parameterYes, old callers omit it, you default it
Add a new value to an enumUsually, only if clients tolerate unknownsIf clients hard-switch on known values
Remove or rename a response fieldYes, the rename that broke the phones
Make an optional field requiredYes, old requests now fail validation
Change a field's type or formatYes, `"42"` to `42` breaks parsers
Tighten validation / shrink a rangeYes, previously valid input now rejected
Change default behaviour or sort orderYes, silent breakage, the worst kind
The two buckets every API change falls into.

Watch out

Enum additions are the classic trap. Adding a new `status` value is additive only if every client treats unknown values gracefully. Plenty of clients do `switch (status)` with no default and crash on anything new. Document, from day one, that clients must tolerate unknown enum members, then additions stay safe.

Versioning strategies: where the version lives

When you do need a breaking version, the question is *where the version number goes*. There are three mainstream answers, and the honest truth is that the best one is usually none of them, keep changes additive and never bump at all.

StrategyHow it looksTrade-off
Additive-only (no version)`/users/42` forever; only ever add fieldsBest default, no migration, no sprawl. Requires discipline: you can never take away
URI versioning`/v1/users/42` then `/v2/users/42`Dead obvious, cache-friendly, easy to route. But the URL of a resource changes, which is conceptually wrong, and tends to breed v3, v4, v5
Header versioning`Accept: application/vnd.api.v2+json`URL stays stable per resource; clean in theory. But invisible in a browser, harder to cache and debug, easy to forget to set
Three strategies for breaking versions, plus the one you should reach for first.

Pick additive-only as your default. Reach for URI versioning when you genuinely need a clean break and you value how visible and routable it is, it is the most common choice for public APIs precisely because it is impossible to misread. Reach for header versioning only if a stable resource URL matters more to you than ease of debugging, and your clients are disciplined services rather than humans poking at URLs.

Whatever you pick, version the whole API, not individual fields. Per-field versioning (name_v2, name_v3) is how you end up with a response object that is a museum of every decision you ever regretted. One version axis, applied consistently.

Code: an additive change done right

Here is the rename from the opening story, done the safe way. We never remove user_name. We add username next to it, both populated from the same source. Old clients keep reading the old key; new clients adopt the new one. No version bump needed, this is pure addition.

GET /users/42, response evolves additively
json
// BEFORE: the original contract
{
  "id": 42,
  "user_name": "sribalaji",
  "email": "sri@example.com"
}

// AFTER: additive, old key stays, new key added.
// Both are populated. Nothing is removed, so nothing breaks.
{
  "id": 42,
  "user_name": "sribalaji",   // deprecated, still served for old clients
  "username": "sribalaji",     // preferred, new clients read this
  "email": "sri@example.com",
  "display_name": "Sri Balaji" // brand-new optional field, also safe
}

On the server, the contract is something you assert against, not something you let drift. A contract test pins the shape so a careless refactor fails CI instead of failing production. Here is the idea in TypeScript.

user.contract.test.ts
typescript
import { describe, it, expect } from "vitest";
import { getUser } from "./api";

describe("GET /users/:id contract", () => {
  it("keeps every field old clients depend on", async () => {
    const res = await getUser(42);

    // These keys are PROMISES. Removing one must fail here,
    // loudly, in CI, long before it reaches a phone.
    expect(res).toHaveProperty("id");
    expect(res).toHaveProperty("user_name"); // deprecated, still required
    expect(res).toHaveProperty("email");

    // Types are part of the contract too.
    expect(typeof res.id).toBe("number");
    expect(typeof res.user_name).toBe("string");
  });

  it("may add new fields freely", async () => {
    const res = await getUser(42);
    // Additive fields are allowed, we assert presence, not absence.
    expect(res).toHaveProperty("username");
  });
});

Contract tests are the seatbelt for the whole discipline. In a multi-service shop, consumer-driven contracts go further: each consumer publishes the subset of the shape it actually reads, and the provider's CI verifies it never breaks any published expectation. The provider learns it broke a client *before* deploying, not after.

Common mistakes that cost hours

  1. Renaming or removing a field in place. The original sin from the opening story. If the name is wrong, add the right one beside it and deprecate the old one. Never delete a field someone might read.
  2. No deprecation window. Marking something deprecated and removing it the same week is not a deprecation, it is an ambush. Announce a date, return Deprecation and Sunset headers, and measure usage until it is genuinely zero.
  3. Sunsetting on a guess. Removing v1 because "surely nobody uses it anymore" without metrics. Instrument the old path first. The one straggler is always your biggest partner.
  4. Version sprawl. Bumping the major version for a change that could have been additive. Every live version is a maintenance tax forever. Most changes should add a field, not a /v3.
  5. Per-field or per-endpoint versions. name_v2, /v3/users next to /v1/orders, now nobody knows what "the API version" even means. One version axis, applied to the whole surface.
  6. Tightening silently. Shrinking a max length, adding a required field, or changing a default sort. These pass your tests and break clients quietly. Treat any narrowing of accepted input as breaking.

Takeaways

The whole article in seven lines

  • You can add to an API; you cannot take away. The moment a client reads your shape, the shape is a promise.
  • Additive changes, new optional fields, new endpoints, new tolerated enum values, ship any time, no version bump.
  • Breaking changes, renames, removals, type changes, new required fields, tighter validation, force the full lifecycle.
  • The lifecycle is: serve v2 beside v1 → announce deprecation with a date → measure real usage → nudge stragglers → sunset.
  • Default to additive-only (no version). Reach for URI versioning for visibility, header versioning only for stable URLs.
  • Version the whole API on one axis. Per-field versions and v3/v4/v5 sprawl are how you drown in maintenance.
  • Contract tests (and consumer-driven contracts) catch a broken promise in CI instead of in the App Store review queue.

Where to go next

Evolution is one pillar of API design. Pair it with the fundamentals of resource modelling and the security model so the contract you are so carefully preserving is also a good contract to begin with.

  • Start with REST API Design, get the resource shapes right before you have to evolve them.
  • Then read Secure API Design, versioning a leaky contract just preserves the leak.
  • Follow the full Backend Engineer path to see where API evolution sits among data modelling, messaging, and reliability.
  • Practice routing and gateways in the networking lab, the gateway is where version routing actually lives.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.