API Versioning in Spring Boot (Production Guide)

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

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

StrategyExampleProsCons
URI/v1/ordersSimple, visible, cacheableVersion leaks into URL structure
HeaderX-API-Version: 2Clean URLsLess discoverable, harder to test in a browser
Media TypeAccept: application/vnd.company.v2+jsonREST-purist correctComplex, poor tooling support
Query Param?version=2Easy to add incrementallyEasy 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

MistakeImpactBetter Practice
v2 changes shared internal logic used by v1v1 behavior silently changes tooIsolate version-specific logic from shared internals
No deprecation windowConsumers broken with no warningAnnounce and enforce a documented sunset window
Mixed versioning strategies across endpointsInconsistent, confusing client integrationPick one strategy and apply it consistently
Version logic scattered across controllersBug fixes applied inconsistently across versionsCentralize in shared service/mapper layers
No per-version traffic monitoringUnsafe sunset decisionsMonitor 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/Sunset headers 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

  1. What are the trade-offs between URI versioning and header versioning?
  2. How would you avoid duplicating business logic between two API versions?
  3. What’s the purpose of the Sunset HTTP header?
  4. How do you decide when it’s safe to remove a deprecated API version?
  5. 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.

External References

Leave a Comment

Your email address will not be published. Required fields are marked *