All writing
·4 min read

API versioning and evolution: change without breaking clients

Prefer additive contracts and tolerant parsers. When you must break, version explicitly, expand-then-contract, and run deprecations like a product launch—not a surprise outage.

API DesignDistributed SystemsScalability

The main rule: new versions should be mostly additive. Old clients keep working, newer ones can use more fields, and nobody discovers a breaking rename through a 500 in production.

That sounds obvious until shipping pressure meets “just rename the field.” This note is a reminder of the boring patterns that keep teams out of that ditch—especially when half your traffic is an old Android build still deserializing Jackson DTOs from 2022.

API versioning & evolution

API evolution strategies overviewURL versioningVersion in the path/api/v1/users/api/v2/usersParallel stacks · explicit for clientsTrade-off: route sprawl + duplicated code pathsCommon: public HTTP, mobile backendsBackward compatibilityAdditive by defaultname, email…+ phone (new)ServerOld clients ignore unknowns · new clients use new fieldsWatch schema bloat; document optional fieldsHeader versioningStable URL, version in metadataGET /usersAPI-Version: 2024-01Router dispatches to the right handler implementationTrade-off: less obvious than /v2 in the browser barCommon: enterprise contracts, Stripe-style APIsDeprecation runwayCommunicate early, enforce lateship v2sunset noticev1 offUse headers + docs + metrics on old routesNeeds coordination—treat as a product launchDefault: additive changes. Version or expand-contract when you must break.

1. Know what is safe vs what is breaking

Change typeSafe for existing clientsBreaking for existing clients
Add a new optional response fieldYes—ignore unknowns wins
Add a new request field with a defaultYes
Tighten validation on an existing fieldOften yes if you only reject garbage you never promised to acceptRisky if you start rejecting values you used to allow
Remove a field clients may readYes
Rename a field (same shape, new key)Yes without dual-write / expand-contract
Change type semantics (string → object)Usually yes

If you are unsure, assume breaking and plan migration telemetry before you merge.


2. Default to additive changes

Evolution should feel like extending a table, not swapping the legs while someone is sitting on it:

  • add optional fields first,
  • keep old keys stable,
  • document defaults and nullability,
  • ship server support before clients depend on new behavior.

Protobuf and OpenAPI both reward this mindset: unknown fields are luggage you can carry until you know what to do with them.


3. Use versioning when you must break things

When additive is not enough, make the break explicit:

  • Path versions (/v1/orders, /v2/orders) are easy to route and easy for humans to grep in logs—at the cost of duplicated handlers and URL sprawl.
  • Header or media-type negotiation keeps URLs pretty and works well for long-lived enterprise clients—at the cost of discoverability and cache semantics you must think through.

There is no magic—only who carries the complexity: routing, docs, or clients.

URL path versioning (sketch)

Parallel v1 and v2 routes/api/v1/usersold mobile builds/api/v2/usersnew fields, stricter validation

4. Use expand-and-contract instead of hard renames

Renaming zipCodepostalCode in one commit is how you win a short PR review and lose a long weekend.

A safer three-step dance:

  1. Expand: accept and return both keys; document zipCode as deprecated.
  2. Migrate: update clients and integrations to emit/read postalCode.
  3. Contract: remove zipCode only when metrics say nobody cares.

If you skip step two, you did not evolve—you gambled.


5. Make your API tolerant on both sides

Servers: ignore unknown JSON keys instead of 400-ing on curiosity. Reserve strict mode for true wire-format validation, not “we have never seen this property before.”

Clients: same rule in reverse—extra fields from a newer server should not explode your DTO layer. In Java land that often means @JsonIgnoreProperties(ignoreUnknown = true) on DTOs you do not fully own, or generated OpenAPI clients with a tolerance policy—pick one and stick to it.

This is how two teams ship on different calendars without a synchronized deploy dance every Tuesday.


6. Have a real deprecation story

“Deprecated” in a comment nobody reads is not a strategy. A real sunset includes:

  • Docs + changelog with dates and replacement fields.
  • Runtime signals: headers like Deprecation, Sunset, or structured problem details—whatever your stack supports consistently.
  • Metrics on old routes and old fields so you know who is still calling grandma’s /v1.
  • A calendar with buffer: announce, nudge, enforce—then actually turn things off.

Treat turning off v1 like a capacity event: comms, dashboards, and rollback if you misjudged adoption.


Strategy cheat sheet

StrategyApproachBest forDownside
URL versioning/v1//v2/ in pathPublic HTTP, mobile backendsduplicated logic, long-lived sprawl
Backward compatibilityadd-only schema disciplineinternal APIs, protobuf servicesfields accumulate; needs hygiene
Header / contract versioningversion in headers or content negotiationenterprise, stable URLsharder to spot in curl screenshots
Deprecation programtimelines + telemetry + commsany API with external clientscoordination cost

Closing

Additive by default, explicit when you break, tolerant on the wire, boring with sunsets.

If clients trust your evolution story, they will actually upgrade. If they do not, they will freeze on old versions forever—and that is on the API owner, not on “lazy consumers.”