Introduction
Every production API eventually needs to change in a way that breaks existing clients — a renamed field, a different response shape, a removed endpoint. Without a versioning strategy, that change either breaks every client simultaneously or forces you to freeze the API forever. Versioning looks like a simple annotation choice on the surface, but in production it becomes an organizational discipline spanning routing configuration, deprecation communication, and a sunset process that has to survive contact with real, slow-moving client integrations.
This guide covers the main versioning strategies available in Spring Boot, how to migrate between versions without breaking consumers, and how to run a deprecation and sunset process that actually holds up in production.
Prerequisites
- Spring MVC request mapping fundamentals
- REST API design basics
- Basic HTTP header semantics
Related reading:
Learning Objectives
- Compare URI, header, media-type, and query-param versioning strategies.
- Implement each strategy in Spring MVC with working code.
- Design a deprecation and sunset process clients can actually plan around.
- Avoid duplicating business logic across API versions.
Table of Contents
- Why API Versioning Matters
- Versioning Strategies Compared
- URI Versioning
- Header Versioning
- Media Type (Content Negotiation) Versioning
- Query Parameter Versioning
- Deprecating Old Versions Safely
- Production Case Studies
- Architect’s Perspective: Trade-offs, Scale & War Story
- Common Mistakes
- Best Practices Checklist
- Decision Tree: Which Strategy to Use
- Interview Questions
- FAQs
Why API Versioning Matters
Without versioning, every breaking change forces a synchronized deployment of every client and server — impossible once you have external consumers, mobile apps in app-store review, or third-party integrations. Versioning lets old and new contracts coexist during a migration window.
┌─────────────┐ /v1/orders ┌───────────────┐
│ Legacy App │ ────────────────────────▶│ │
└─────────────┘ │ Spring Boot │
┌─────────────┐ /v2/orders │ API │
│ New App │ ────────────────────────▶│ │
└─────────────┘ └───────────────┘
Versioning Strategies Compared
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URI | /v1/orders | Simple, visible, cacheable | Version leaks into URL structure |
| Header | X-API-Version: 2 | Clean URLs | Less discoverable, harder to test in a browser |
| Media Type | Accept: application/vnd.company.v2+json | REST-purist correct | Complex, poor tooling support |
| Query Param | ?version=2 | Easy to add incrementally | Easy to omit accidentally, less standard |
URI Versioning
@RestController
@RequestMapping("/v1/orders")
public class OrderControllerV1 {
@GetMapping("/{id}")
public OrderResponseV1 getOrder(@PathVariable Long id) {
return orderServiceV1.get(id);
}
}
@RestController
@RequestMapping("/v2/orders")
public class OrderControllerV2 {
@GetMapping("/{id}")
public OrderResponseV2 getOrder(@PathVariable Long id) {
return orderServiceV2.get(id);
}
}
Keep version-specific controllers thin — delegate to a shared service layer wherever the business logic hasn’t actually changed, so v1 and v2 don’t silently drift into duplicated bugs.
Header Versioning
@GetMapping(value = "/orders/{id}", headers = "X-API-Version=2")
public OrderResponseV2 getOrderV2(@PathVariable Long id) {
return orderServiceV2.get(id);
}
@GetMapping(value = "/orders/{id}", headers = "X-API-Version=1")
public OrderResponseV1 getOrderV1(@PathVariable Long id) {
return orderServiceV1.get(id);
}
Header versioning keeps URLs stable long-term but requires every consumer to reliably set the header, which is easy to miss in ad-hoc scripts and integrations.
Media Type (Content Negotiation) Versioning
@GetMapping(value = "/orders/{id}", produces = "application/vnd.acme.v2+json")
public OrderResponseV2 getOrderV2(@PathVariable Long id) {
return orderServiceV2.get(id);
}
This is the most “correct” REST approach by strict specification, but rarely used outside large API-first organizations because tooling often handles custom media types poorly.
Query Parameter Versioning
@GetMapping(value = "/orders/{id}", params = "version=2")
public OrderResponseV2 getOrderV2(@PathVariable Long id) {
return orderServiceV2.get(id);
}
Easiest to retrofit onto an existing unversioned API, but a missing or default query param silently routes to whichever version matches first — a common source of subtle bugs.
Deprecating Old Versions Safely
@GetMapping("/v1/orders/{id}")
public ResponseEntity<OrderResponseV1> getOrder(@PathVariable Long id) {
OrderResponseV1 response = orderServiceV1.get(id);
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "Wed, 31 Dec 2026 23:59:59 GMT")
.header("Link", "<https://api.acme.com/v2/orders>; rel=\"successor-version\"")
.body(response);
}
The Deprecation and Sunset headers let clients and monitoring tools detect deprecated usage programmatically. Track actual traffic per version in your metrics/dashboards before removing an old version.
Production Case Studies
Case Study 1 – Partner Integration Broken by an On-Schedule Sunset
Symptoms: A major partner’s integration broke immediately after a v1 sunset that had been announced 90 days in advance with near-zero observed v1 traffic.
Investigation: The partner’s primary integration had migrated to v2, but a rarely-triggered monthly batch job was still hardcoded to v1 and hadn’t executed during the traffic-observation window.
Root Cause: Sunset decisions based on a fixed calendar window rather than a window covering at least one full cycle of known low-frequency consumer patterns.
Solution: v1 was temporarily restored, the partner’s batch job migrated, and the sunset policy was changed to require observation across a minimum of one full low-frequency usage cycle before removal.
Case Study 2 – Version Drift Between v1 and v2 Business Logic
Symptoms: A bug fix applied to v2 didn’t resolve the same customer-reported issue for clients still on v1.
Investigation: v1 and v2 controllers had independently duplicated business logic rather than sharing a service layer, so the fix only touched the v2 code path.
Root Cause: No shared service layer between version-specific controllers.
Solution: Refactored both controllers to delegate to a single shared service, with version-specific logic isolated only to request/response mapping.
Architect’s Perspective: Trade-offs, Scale & War Story
Trade-off Analysis: Versioning Cost Is Organizational, Not Just Technical
Every additional live API version is a permanent maintenance liability — bug fixes and security patches must be considered against every supported version. An architect’s real job isn’t picking URI vs. header versioning; it’s setting and enforcing a maximum number of concurrently supported versions and a hard sunset policy.
At Scale: Version Proliferation and Test Matrix Explosion
Each supported version multiplies your effective test matrix. Beyond two or three concurrently supported versions, most teams stop actually testing older versions properly and instead rely on “it hasn’t broken yet.”
When NOT to Version at All
For genuinely internal APIs consumed only by services deployed in lockstep, full versioning machinery can be overkill — a coordinated breaking change is sometimes simpler than maintaining parallel versions indefinitely.
Observability: Tracking Version Usage to Make Sunset Decisions Defensible
Tag every request with its resolved API version in logs and metrics, and build a dashboard of traffic-by-version over time — sunset decisions should be backed by data, not assumption.
Production War Story
See Case Study 1 above — the sunset-observation-window lesson is the clearest architect-level takeaway in this guide: near-zero average traffic over a fixed window is not the same as zero usage across every consumer pattern.
Common Mistakes
| Mistake | Impact | Better Practice |
|---|---|---|
| v2 changes shared internal logic used by v1 | v1 behavior silently changes too | Isolate version-specific logic from shared internals |
| No deprecation window | Consumers broken with no warning | Announce and enforce a documented sunset window |
| Mixed versioning strategies across endpoints | Inconsistent, confusing client integration | Pick one strategy and apply it consistently |
| Version logic scattered across controllers | Bug fixes applied inconsistently across versions | Centralize in shared service/mapper layers |
| No per-version traffic monitoring | Unsafe sunset decisions | Monitor and require near-zero traffic before removal |
Best Practices Checklist
- [ ] One clearly documented versioning strategy used consistently across the API
- [ ] Shared service layer reused across versions where behavior hasn’t changed
- [ ]
Deprecation/Sunsetheaders on outgoing deprecated responses - [ ] Per-version traffic monitored before removing a version, across a full low-frequency usage cycle
- [ ] A published migration guide for every breaking version bump
- [ ] Semantic versioning discipline — only bump major version for breaking changes
Decision Tree: Which Strategy to Use
Choosing a versioning strategy
│
├─ Public API with external/third-party consumers? → URI versioning (most discoverable)
├─ Internal microservice-to-microservice API? → Header versioning (clean URLs, controlled clients)
├─ Strict REST/HATEOAS compliance required? → Media type versioning
└─ Quick retrofit onto an existing unversioned API? → Query param versioning (short-term only)
Interview Questions
- What are the trade-offs between URI versioning and header versioning?
- How would you avoid duplicating business logic between two API versions?
- What’s the purpose of the
SunsetHTTP header? - How do you decide when it’s safe to remove a deprecated API version?
- Why is media-type versioning considered the most “RESTful” but least commonly adopted approach?
FAQs
Q: Which versioning strategy is most common in production Spring Boot APIs?
URI versioning is the most widely adopted in practice because it’s explicit, cacheable, and easy for any client to use without special tooling.
Q: Do I need to version an internal API that only my own services call?
It’s still recommended once more than one service depends on it, since internal consumers deploy on independent schedules too.
Continue Reading on SpringBootFixes
Conclusion
API versioning is less a technical decision than an organizational commitment — the strategy you pick matters less than whether you enforce a version ceiling, back sunset decisions with real traffic data, and keep business logic shared across versions instead of duplicated. Get those three disciplines right and the specific mechanism (URI vs. header vs. media type) becomes a minor implementation detail.
