API versioning strategies in Spring Boot for production
Most teams version their APIs too early and abandon versioning too late. The cost of supporting multiple API versions grows linearly with each new endpoint you add, yet many teams end up maintaining three or four versions simultaneously without a clear deprecation plan. Getting this decision right at the start—and sticking to it—saves your team months of duplicated effort down the road.
Understanding the three main strategies
When you need API versioning strategies in Spring Boot for production, you're choosing between three approaches, each with distinct operational trade-offs. Let's compare them directly.
URL path versioning: /v1 and /v2
URL path versioning puts the version number directly in the route: /api/v1/users versus /api/v2/users. This is the most visible and easiest to implement.
Trade-offs: Version numbers are immediately obvious to clients and proxy servers. Caching works cleanly because the URL differs. Routing is straightforward—your framework routes to different controllers or services. The downside is that URLs become cluttered, and you must maintain separate endpoint definitions for each version.
Example implementation:
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public UserDtoV1 getUser(@PathVariable Long id) {
return userService.getUserV1(id);
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public UserDtoV2 getUser(@PathVariable Long id) {
return userService.getUserV2(id);
}
}When you have many endpoints, this duplicates controller logic across versions. A team handling 50 endpoints suddenly maintains 100 controller classes.
Header-based versioning
Header versioning uses a custom header like X-API-Version: 2 or standardized Accept-Version: 2. URLs stay clean; the version lives in HTTP metadata.
Trade-offs: URLs remain identical regardless of version, keeping them cleaner and easier to document. Clients can test different versions against the same URL by changing headers in tools like curl or Postman. However, caching becomes problematic—proxies and CDNs often ignore custom headers, potentially serving the wrong version. Testing also requires remembering to set headers each time.
Example implementation:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity getUser(
@PathVariable Long id,
@RequestHeader(value = "X-API-Version", defaultValue = "1") int version) {
if (version == 1) {
return ResponseEntity.ok(userService.getUserV1(id));
} else if (version == 2) {
return ResponseEntity.ok(userService.getUserV2(id));
}
return ResponseEntity.status(400).body("Unsupported version");
}
}This approach works well for internal APIs where you control client behavior, but external clients often struggle to remember and pass headers consistently.
Media type versioning
Media type (content negotiation) versioning embeds version info in the Accept header: Accept: application/vnd.mycompany.v2+json. This is the most RESTful approach and most precise.
Trade-offs: Versioning becomes content negotiation, which aligns with HTTP semantics. Different clients requesting different representations of the same resource is standard REST. The downside: clients and testing tools rarely support custom media types natively. Postman requires manual header entry. Browser-based clients find this confusing. Caching and routing become more complex.
Example implementation:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}",
produces = "application/vnd.mycompany.v1+json")
public UserDtoV1 getUserV1(@PathVariable Long id) {
return userService.getUserV1(id);
}
@GetMapping("/{id}",
produces = "application/vnd.mycompany.v2+json")
public UserDtoV2 getUserV2(@PathVariable Long id) {
return userService.getUserV2(id);
}
}You benefit from having a single URL path, but now you're managing multiple method signatures for the same resource.
Preferring additive changes over new versions
Before choosing any versioning strategy, ask: do you actually need a new version? Most API changes don't require one.
Additive changes are version-safe: Adding an optional field to a response breaks no existing clients. If you return { "id": 1, "name": "Alice", "email": "alice@example.com" } instead of { "id": 1, "name": "Alice" }, old clients simply ignore the new field. No version bump needed.
Real scenario: Your users endpoint originally returned name and ID. Six months later, product asks for email addresses. Add email as an optional field in the response. Old clients continue working. New clients use the email field. A year later, you add a status field. Still no version required.
When versioning becomes necessary: You need a new version when you remove a field, change a field's type, rename a field, or alter request/response semantics. Removing the deprecated "username" field in favor of "email" breaks clients expecting username—that's a version bump.
Practical approach:
// v1 response shape (grandfathered)
public class UserResponseV1 {
public Long id;
public String name;
}
// v2 response shape (additive)
public class UserResponseV2 extends UserResponseV1 {
public String email; // new field, optional
public String phone; // new field, optional
}
// v3 response shape (breaking: removed name, renamed to fullName)
public class UserResponseV3 {
public Long id;
public String email;
public String phone;
public String fullName; // breaking change from "name"
}By structuring responses to support additive fields, you extend the life of a single API version from months to years.
Spring Framework 7 support for declarative versioning
Spring Framework 7 introduced @RestControllerAdvice and improved the @GetMapping annotation to support versioning parameters natively, making path and header versions declarative and less boilerplate-heavy.
Using Spring's ApiVersion abstraction: Modern Spring projects use a versioning resolver to extract and validate versions centrally, then route requests to appropriate handler methods.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}", params = "version=1")
public UserDtoV1 getUserV1(@PathVariable Long id) {
return userService.getUserV1(id);
}
@GetMapping(value = "/{id}", params = "version=2")
public UserDtoV2 getUserV2(@PathVariable Long id) {
return userService.getUserV2(id);
}
}For header-based versioning, Spring's built-in header matching is cleaner:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public UserDtoV1 getUserV1(@PathVariable Long id) {
return userService.getUserV1(id);
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public UserDtoV2 getUserV2(@PathVariable Long id) {
return userService.getUserV2(id);
}
}This eliminates the conditional logic inside handler methods and makes method routing explicit. Spring's routing engine selects the correct method before your code runs.
Deprecating with dates and tracking old-version traffic
A version without an expiration date never goes away. Plan deprecation from day one: decide when v1 sunsets, communicate it publicly, and actually retire it on schedule.
Deprecation announcement strategy: Publish a timeline. "v1 of the Users API will be retired on June 1, 2025. All clients must migrate to v2 by May 31, 2025." Include migration guides. Set calendar reminders.
Track old-version traffic: Before retiring a version, instrument it to see who's still calling it.
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
private static final Logger logger = LoggerFactory.getLogger(UserControllerV1.class);
@GetMapping("/{id}")
public UserDtoV1 getUser(@PathVariable Long id,
HttpServletRequest request) {
String clientIp = request.getRemoteAddr();
String userAgent = request.getHeader("User-Agent");
logger.warn("Deprecated v1 API called from IP={} UA={}",
clientIp, userAgent);
return userService.getUserV1(id);
}
}Ship metrics to your observability platform (Prometheus, DataDog, etc.). Dashboard the daily call count to each deprecated version. When v1 traffic drops to zero for two weeks, you have confidence that clients have migrated.
Gradual throttling approach: Some teams increase latency or add rate limits to old versions as the sunset date approaches. This encourages migration without abrupt outages.
@GetMapping("/{id}")
public ResponseEntity getUser(@PathVariable Long id)
throws InterruptedException {
LocalDate now = LocalDate.now();
LocalDate sunsetDate = LocalDate.of(2025, 6, 1);
if (now.isAfter(sunsetDate.minusWeeks(4))) {
Thread.sleep(500); // add 500ms latency in final 4 weeks
}
return ResponseEntity.ok(userService.getUserV1(id));
}On sunset day, return 410 Gone with a link to migration docs, not 404.
Common mistakes
- Versioning without a sunset plan: Teams version "just in case" and end up supporting five versions indefinitely. Always decide upfront: what triggers end-of-life?
- Mixing versioning strategies: Using URL paths for some endpoints and headers for others confuses clients. Pick one strategy per API.
- Not treating additive changes as version-safe: Creating v2 to add a single optional field. This wastes opportunities to extend v1's life.
- Retiring versions without tracking migration: Turning off v1 cold turkey and discovering your biggest client was still using it. Monitor traffic before shutdown.
- Ignoring caching implications: Header-based versions cache poorly. If you use headers, explicitly set cache headers or move behind a versioning proxy that normalizes versions to URLs.
Key takeaways
- Choose one versioning strategy: URL path (/v1) for simplicity and visibility, headers for cleaner URLs, or media types for pure REST. Avoid mixing.
- Additive changes (new optional fields) do not require a version. Reserve versioning for breaking changes: removed fields, type changes, semantic shifts.
- Spring Framework 7 makes header and parameter routing declarative via
headersandparamsattributes on@GetMapping, reducing boilerplate. - Announce deprecation timelines and track old-version traffic before retiring. Use metrics or gradual throttling to encourage migration without surprise outages.
- Version only when you must. A single, long-lived API version costs less to maintain than three short-lived versions.






Comments
Loading comments…