Back to Blog
spring-bootapirestversioningjavaarchitecture

API Versioning in Spring Boot: 4 Strategies and When to Use Each (2026)

Breaking API changes break clients. Versioning gives you the freedom to evolve your API without forcing every consumer to update at the same time. Here are the 4 main approaches.

J

JOptimize Team

May 30, 2026· 8 min read

Every API that has users eventually faces the same problem: you need to change something in a way that would break existing clients. Maybe you're renaming a field, changing a response structure, or removing an endpoint. Without versioning, you have two bad choices: break your clients or never change anything.

API versioning lets you introduce changes as a new version while keeping the old version running for existing clients. Clients migrate at their own pace. You can eventually deprecate and remove old versions once adoption is high enough.

The question isn't whether to version — it's how.


Strategy 1: URI Path Versioning

The version is part of the URL path: /api/v1/orders, /api/v2/orders.

@RestController @RequestMapping("/api/v1/orders") public class OrderControllerV1 { @GetMapping("/{id}") public OrderDtoV1 getOrder(@PathVariable Long id) { Order order = orderService.findById(id); return OrderMapperV1.toDto(order); } } @RestController @RequestMapping("/api/v2/orders") public class OrderControllerV2 { @GetMapping("/{id}") public OrderDtoV2 getOrder(@PathVariable Long id) { Order order = orderService.findById(id); return OrderMapperV2.toDto(order); // Different DTO structure } }

Pros: Simple to understand, easy to test in a browser, works with every HTTP client, straightforward caching (different URLs = different cache keys).

Cons: Violates REST purists' view that a URL should identify a resource, not a version of a resource. Creates URL proliferation if you have many endpoints.

When to use: Most REST APIs. This is the industry standard for pragmatic reasons — it's simple and obvious. GitHub, Stripe, and Twilio all use URI versioning.


Strategy 2: Request Header Versioning

The version is passed in a custom HTTP header: X-API-Version: 2.

@RestController @RequestMapping("/api/orders") public class OrderController { @GetMapping("/{id}") public ResponseEntity<?> getOrder( @PathVariable Long id, @RequestHeader(value = "X-API-Version", defaultValue = "1") int version) { Order order = orderService.findById(id); return switch (version) { case 1 -> ResponseEntity.ok(OrderMapperV1.toDto(order)); case 2 -> ResponseEntity.ok(OrderMapperV2.toDto(order)); default -> ResponseEntity.badRequest() .body("Unsupported API version: " + version); }; } }

A cleaner approach for large APIs uses a custom annotation and HandlerInterceptor:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ApiVersion { int[] value(); // Supported versions for this method } @GetMapping("/{id}") @ApiVersion({1}) public OrderDtoV1 getOrderV1(@PathVariable Long id) { ... } @GetMapping("/{id}") @ApiVersion({2, 3}) public OrderDtoV2 getOrderV2(@PathVariable Long id) { ... }

Pros: URLs stay clean, version is explicit in the request.

Cons: Can't bookmark or share URLs, harder to test in browser, proxies and CDNs may not cache correctly if they don't consider the header.

When to use: Internal APIs between services you control, where clients are always code.


Strategy 3: Media Type (Content Negotiation) Versioning

The version is embedded in the Accept header as a custom media type: Accept: application/vnd.myapp.v2+json

@RestController @RequestMapping("/api/orders") public class OrderController { private static final String V1_MEDIA = "application/vnd.myapp.v1+json"; private static final String V2_MEDIA = "application/vnd.myapp.v2+json"; @GetMapping(value = "/{id}", produces = V1_MEDIA) public OrderDtoV1 getOrderV1(@PathVariable Long id) { return OrderMapperV1.toDto(orderService.findById(id)); } @GetMapping(value = "/{id}", produces = V2_MEDIA) public OrderDtoV2 getOrderV2(@PathVariable Long id) { return OrderMapperV2.toDto(orderService.findById(id)); } }

Pros: Most "RESTfully correct" — uses HTTP content negotiation as designed. The URL identifies the resource; the media type specifies the representation.

Cons: Complex to implement and understand, difficult to test without API tools, poor browser support, unfamiliar to most developers.

When to use: If you're building a hypermedia API (HAL, JSON:API) and care deeply about REST constraints. Rarely the right choice for standard CRUD APIs.


Strategy 4: Query Parameter Versioning

The version is a query parameter: /api/orders/1?version=2.

@GetMapping("/{id}") public ResponseEntity<?> getOrder( @PathVariable Long id, @RequestParam(defaultValue = "1") int version) { Order order = orderService.findById(id); return switch (version) { case 1 -> ResponseEntity.ok(OrderMapperV1.toDto(order)); case 2 -> ResponseEntity.ok(OrderMapperV2.toDto(order)); default -> ResponseEntity.badRequest().body("Unknown version"); }; }

Pros: Simple to implement, easy to test in browser.

Cons: Query parameters are for filtering, not versioning — semantically wrong. Breaks REST resource identity. Caching becomes tricky.

When to use: Rarely. Legacy APIs or quick prototypes.


Deprecation: How to Kill Old Versions Gracefully

Versioning without a deprecation strategy leads to supporting v1 forever. Set a lifecycle policy from the start:

@GetMapping("/{id}") public ResponseEntity<OrderDtoV1> getOrderV1(@PathVariable Long id) { Order order = orderService.findById(id); return ResponseEntity.ok() // Inform clients this version is going away .header("Deprecation", "true") .header("Sunset", "Sat, 31 Dec 2026 23:59:59 GMT") // RFC 8594 .header("Link", "</api/v2/orders/" + id + ">; rel=\"successor-version\"") .body(OrderMapperV1.toDto(order)); }

The Sunset header (RFC 8594) is the standard way to communicate version end-of-life. Good API clients can parse it and alert developers. Set the sunset date at least 6 months in advance for external APIs.


Versioning the DTO, Not the Service

A common mistake is versioning the entire stack — controllers, services, repositories. Only the contract (DTO) needs versioning:

# Correct structure:
OrderController (single class, routes to mappers)
  ↓
OrderService (single class — business logic doesn't version)
  ↓
OrderRepository (single class — data model doesn't version)
  ↓
OrderMapperV1 → OrderDtoV1  (contracts version independently)
OrderMapperV2 → OrderDtoV2

The service and repository stay stable. Only the mapping layer and DTOs change between versions. This keeps the versioning surface minimal.


Common Mistakes to Avoid

  • Versioning too early — don't add versioning before you have real clients; it's unnecessary complexity if you control all consumers
  • No deprecation timeline — announcing a version is deprecated without a sunset date means clients never migrate; set a specific date
  • Versioning everything — most endpoint changes are additive (new fields, new endpoints) and don't require a new version; only break new versions when you're removing or renaming
  • Different versioning strategies in the same API — pick one strategy and apply it consistently; mixing URI and header versioning in the same API confuses everyone

Summary

URI path versioning (/api/v1/, /api/v2/) is the right default for most Spring Boot APIs — it's simple, universally understood, and works with every tool. Version only the DTO layer, not the service layer. Add Sunset headers when deprecating old versions, and give consumers at least 6 months to migrate.


Keep Your Versioned APIs Performant

Multiple API versions mean multiple code paths. JOptimize ensures each version doesn't introduce N+1 queries or missing pagination as the DTO layer evolves.

Version your API. Don't version your performance issues.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.