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.
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
1. Know what is safe vs what is breaking
| Change type | Safe for existing clients | Breaking for existing clients |
|---|---|---|
| Add a new optional response field | Yes—ignore unknowns wins | — |
| Add a new request field with a default | Yes | — |
| Tighten validation on an existing field | Often yes if you only reject garbage you never promised to accept | Risky if you start rejecting values you used to allow |
| Remove a field clients may read | — | Yes |
| 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)
4. Use expand-and-contract instead of hard renames
Renaming zipCode → postalCode in one commit is how you win a short PR review and lose a long weekend.
A safer three-step dance:
- Expand: accept and return both keys; document
zipCodeas deprecated. - Migrate: update clients and integrations to emit/read
postalCode. - Contract: remove
zipCodeonly 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
| Strategy | Approach | Best for | Downside |
|---|---|---|---|
| URL versioning | /v1/ … /v2/ in path | Public HTTP, mobile backends | duplicated logic, long-lived sprawl |
| Backward compatibility | add-only schema discipline | internal APIs, protobuf services | fields accumulate; needs hygiene |
| Header / contract versioning | version in headers or content negotiation | enterprise, stable URLs | harder to spot in curl screenshots |
| Deprecation program | timelines + telemetry + comms | any API with external clients | coordination 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.”