API Versioning: Evolving Without Breaking Clients
- ShiftQuality Contributor
- 4 days ago
- 5 min read
The previous posts in this path covered event-driven architecture, service boundaries, and microservices communication. This post covers a problem that every API faces: evolution — how to change and improve your API while honoring the promise you made to existing clients that the API they built against will keep working.
An API is a contract. When a client integrates with your API, they write code that depends on specific endpoints, request formats, and response structures. Changing any of these without coordination breaks that client's code. The client does not know about your change. They discover it when their application crashes at 2 AM on a Tuesday, and their relationship with your service — and your company — is damaged.
API versioning is the discipline of managing change so that existing clients continue working while new clients can access improved functionality.
What Counts as a Breaking Change
Not all changes break clients. Understanding the distinction prevents unnecessary version bumps.
Non-breaking changes (safe to make without versioning): adding a new optional field to a response, adding a new endpoint, adding a new optional query parameter, loosening a validation constraint (accepting input you previously rejected), adding a new enum value that clients can safely ignore.
Breaking changes (require a new version): removing a field from a response, renaming a field, changing a field's type, removing or renaming an endpoint, adding a required field to a request, tightening a validation constraint (rejecting input you previously accepted), changing the meaning of an existing field.
The rule of thumb: additive changes are safe. Subtractive or mutative changes are breaking. If existing client code that currently works would fail or produce incorrect results after the change, it is breaking.
This asymmetry shapes how you design APIs from the start. Prefer optional fields over required fields (you can make an optional field required later, but not the reverse). Use explicit types over overloaded fields (a field that means different things depending on context is hard to evolve). Document the stability of each field so clients know what they can depend on.
Versioning Strategies
There is no universally correct versioning strategy. Each approach has trade-offs.
URL path versioning (/api/v1/orders, /api/v2/orders). The version is explicit in the URL. Clients can clearly see which version they are using. Different versions can coexist easily. The downside: every version creates a new set of endpoints, and maintaining multiple live versions multiplies the code and infrastructure.
Header versioning (Accept: application/vnd.myapi.v2+json). The version is in the request header, keeping URLs clean. The downside: the version is hidden — you cannot see it in a URL shared in documentation or chat, and some client tools do not make headers easy to manage.
Query parameter versioning (/api/orders?version=2). Simple to implement and visible in URLs. The downside: it looks like a filter parameter rather than a fundamental API characteristic, and caching can be complicated.
No explicit versioning. Instead of version numbers, the API evolves through backward-compatible changes only. Breaking changes are introduced by creating new endpoints (/api/orders → /api/orders-v2 or a new resource name) rather than bumping a version number. This approach works well for APIs that evolve slowly and can commit to backward compatibility.
For most teams, URL path versioning is the pragmatic choice. It is explicit, easy to understand, easy to route, and well-supported by every HTTP framework and API gateway.
Managing the Transition
A new version is only useful if clients can migrate to it. The transition requires communication, documentation, and time.
Announce before you ship. Give clients advance notice that a new version is coming, what is changing, and why. Provide a migration guide that maps old behavior to new behavior: "the customer_name field is now split into first_name and last_name."
Run versions in parallel. When v2 launches, v1 continues to operate. Clients migrate on their own schedule. The parallel period should be long enough for all active clients to migrate — typically 6-12 months, depending on your client ecosystem.
Deprecation notices. Add deprecation headers to v1 responses once v2 is available. Log v1 usage so you can identify clients that have not migrated. Reach out directly to high-usage clients that are still on v1 as the end-of-life date approaches.
Sunset. Eventually, v1 is turned off. This date should be communicated well in advance and treated as a contract commitment. Breaking it by sunsetting early destroys trust. Extending it if clients genuinely need more time builds trust.
Internal API Versioning
APIs between internal services face the same versioning challenge as external APIs — but with different trade-offs.
Internal clients are under your control. You can coordinate changes. You can update clients and providers simultaneously. This makes it tempting to skip versioning for internal APIs and just change both sides at once. This works for small teams with few services. It fails for larger organizations where service teams operate on different schedules.
The pragmatic internal approach: use contract testing (covered in a previous post) instead of formal versioning. The consumer defines what it expects. The provider verifies it delivers. As long as the contracts pass, the API can evolve freely. When a change would break a contract, the teams coordinate directly — updating the consumer before or alongside the provider change.
For internal APIs that have many consumers (a shared authentication service, a core data service), formal versioning may still be justified. The coordination cost of updating 15 consumers simultaneously often exceeds the cost of maintaining two versions temporarily.
Design for Evolvability
The best versioning strategy is minimizing the need for breaking changes in the first place.
Use envelopes. Wrap responses in a standard envelope: {"data": {...}, "meta": {...}}. New metadata can be added to meta without affecting the data structure.
Be liberal in what you accept. Ignore unknown fields in requests rather than rejecting them. This allows clients to send fields that are valid in a newer version without breaking in the current version.
Use links, not IDs. Instead of returning "user_id": 123 and expecting the client to construct the URL, return "user_url": "/api/v1/users/123". When the URL structure changes, the link updates automatically.
Document extensibility. Tell clients which parts of the response are stable (will not change without a new version) and which are extensible (new fields may be added). Clients should be written to tolerate new fields they do not recognize.
The Takeaway
API versioning manages the tension between evolving the API and honoring existing contracts. Additive changes are safe. Breaking changes require a new version. URL path versioning is the pragmatic default. Transition periods with parallel versions, migration guides, and deprecation notices give clients time to adapt.
The goal is not to avoid change — it is to make change manageable. Clients should never be surprised by a breaking change. They should be informed, supported with migration resources, and given time to adapt. That is the difference between an API that people trust and an API that people dread depending on.
Next in the "Architecture for Real Systems" learning path: We'll cover observability-driven development — designing systems with diagnostics as a first-class concern, not an afterthought.



Comments