DEV Community

Cover image for API Design: Evolution & Versioning
Sri Balaji
Sri Balaji

Posted on Originally published at thesimplifiedtech.com

API Design: Evolution & Versioning

⚡ TL;DR: The rule that keeps clients alive: you can add fields, never remove or rename them. Make changes additive, version only for true breaks, run side-by-side with a real deprecation policy, and let contract testing catch breakage before users do.

Contents

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 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.

In the real world In tech
A contract both parties already signed Your published API response shape
Adding a new clause both sides agree to later Adding a new optional field
Quietly deleting a clause the other party relies on Removing or renaming a field
A signed amendment with a start date A new API version, announced and dated
A notice period before the old terms expire A 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.

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

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. 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. 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. 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. 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. 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.

Change Additive / safe Breaking
Add a new optional field to a response Yes, old clients ignore it
Add a new endpoint or method Yes, nobody called it before
Add a new optional request parameter Yes, old callers omit it, you default it
Add a new value to an enum Usually, only if clients tolerate unknowns If clients hard-switch on known values
Remove or rename a response field Yes, the rename that broke the phones
Make an optional field required Yes, old requests now fail validation
Change a field's type or format Yes, "42" to 42 breaks parsers
Tighten validation / shrink a range Yes, previously valid input now rejected
Change default behaviour or sort order Yes, silent breakage, the worst kind

The two buckets every API change falls into.

⚠️ Warning: 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.

Strategy How it looks Trade-off
Additive-only (no version) /users/42 forever; only ever add fields Best 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

// 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
}
Enter fullscreen mode Exit fullscreen mode

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

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");
  });
});
Enter fullscreen mode Exit fullscreen mode

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.

Originally published on TheSimplifiedTech, where this guide is interactive, with in-browser terminal labs and diagrams. Learn cloud and DevOps by doing, no videos.

Top comments (0)