Spring Boot: API Versioning Strategies: URI, Header & Content Negotiation
API versioning lets you evolve your REST API without breaking existing clients. There is no single correct approach: URI path versioning is the most visible and cache-friendly; header-based versioning keeps URLs clean; content negotiation via the Accept header is most RESTful. Each has real trade-offs that affect client UX, caching, and documentation.
Contents
Strategy 1: URI Path Versioning
Include the version in the URL path: /api/v1/users/api/v2/users
// V1 controller
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public UserResponseV1 getUser(@PathVariable Long id) {
User user = userService.findById(id);
return new UserResponseV1(user.getId(), user.getFullName(), user.getEmail());
}
}
// V2 controller — adds phone and splits name into first/last
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public UserResponseV2 getUser(@PathVariable Long id) {
User user = userService.findById(id);
return new UserResponseV2(user.getId(), user.getFirstName(),
user.getLastName(), user.getEmail(), user.getPhone());
}
}
// V1 response — backwards compatible
public record UserResponseV1(Long id, String fullName, String email) {}
// V2 response — breaking change: splits fullName into first + last, adds phone
public record UserResponseV2(Long id, String firstName, String lastName,
String email, String phone) {}
controller.v1controller.v2Strategy 2: Custom Request Header
Route by a custom header such as X-API-Version: 2
@RestController
@RequestMapping("/api/users")
public class UserController {
// Default (no header) and v1
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public UserResponseV1 getUserV1(@PathVariable Long id) {
User user = userService.findById(id);
return new UserResponseV1(user.getId(), user.getFullName(), user.getEmail());
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public UserResponseV2 getUserV2(@PathVariable Long id) {
User user = userService.findById(id);
return new UserResponseV2(user.getId(), user.getFirstName(),
user.getLastName(), user.getEmail(), user.getPhone());
}
}
# Client sends:
curl -H "X-API-Version: 2" https://api.example.com/api/users/42
Strategy 3: Accept Header (Content Negotiation)
Encode the version in the AcceptAccept: application/vnd.example.v2+json
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping(value = "/{id}",
produces = "application/vnd.example.v1+json")
public UserResponseV1 getUserV1(@PathVariable Long id) {
User user = userService.findById(id);
return new UserResponseV1(user.getId(), user.getFullName(), user.getEmail());
}
@GetMapping(value = "/{id}",
produces = "application/vnd.example.v2+json")
public UserResponseV2 getUserV2(@PathVariable Long id) {
User user = userService.findById(id);
return new UserResponseV2(user.getId(), user.getFirstName(),
user.getLastName(), user.getEmail(), user.getPhone());
}
}
curl -H "Accept: application/vnd.example.v2+json" https://api.example.com/api/users/42
Custom @ApiVersion Annotation
For larger APIs with many versioned endpoints, a custom annotation + RequestMappingHandlerMapping@ApiVersion("v2")
import java.lang.annotation.*;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
String value(); // e.g. "v1", "v2"
}
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.core.annotation.AnnotationUtils;
public class VersionedRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected void detectHandlerMethods(Object handler) {
super.detectHandlerMethods(handler);
}
// Prepend /api/{version} to every mapped path for versioned controllers
@Override
protected org.springframework.web.servlet.mvc.condition.RequestCondition<?>
getCustomTypeCondition(Class<?> handlerType) {
ApiVersion annotation = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
return annotation != null ? new ApiVersionCondition(annotation.value()) : null;
}
}
// Usage — clean controller with no version in @RequestMapping
@RestController
@ApiVersion("v2")
@RequestMapping("/users") // resolved to /api/v2/users
public class UserControllerV2 {
@GetMapping("/{id}")
public UserResponseV2 getUser(@PathVariable Long id) { ... }
}
Deprecating Old Versions
Add the DeprecationSunset
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public ResponseEntity<UserResponseV1> getUserV1(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "Sat, 01 Jan 2026 00:00:00 GMT")
.header("Link", "<https://api.example.com/api/v2/users/" + id + ">; rel=\"successor-version\"")
.body(new UserResponseV1(user.getId(), user.getFullName(), user.getEmail()));
}
// Or apply globally via a HandlerInterceptor for all v1 routes
@Component
public class DeprecationInterceptor implements HandlerInterceptor {
@Override
public void postHandle(HttpServletRequest req, HttpServletResponse res,
Object handler, ModelAndView mv) {
if (req.getRequestURI().contains("/v1/")) {
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", "Sat, 01 Jan 2026 00:00:00 GMT");
}
}
}
Strategy Comparison
| Aspect | URI Path | Custom Header | Accept Header |
|---|---|---|---|
| Discoverability | ✅ Excellent | ⚠️ Hidden | ⚠️ Hidden |
| Browser / curl friendliness | ✅ Easy | ⚠️ Needs header | ❌ Awkward |
| HTTP cache friendly | ✅ Yes | ⚠️ Needs Vary header | ⚠️ Needs Vary header |
| Clean URLs | ❌ Version in URL | ✅ Clean | ✅ Clean |
| RESTfulness | ⚠️ Resource changes URL | ⚠️ Non-standard | ✅ Uses HTTP content negotiation |
| OpenAPI / Swagger support | ✅ Easy | ⚠️ Extra config | ⚠️ Extra config |
| Industry adoption | ✅ Most common | Moderate | Less common |
Recommendation: Use URI path versioning (/api/v1//api/v2/