Contents
- Strategy 1 — URI Path Versioning
- Strategy 2 — Custom Request Header
- Strategy 3 — Accept Header (Content Negotiation)
- Custom @ApiVersion Annotation
- Deprecating Old Versions
- Strategy Comparison
Include the version in the URL path: /api/v1/users, /api/v2/users. The most common approach — easy to understand, works with every HTTP client, and is cache-friendly.
// 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) {}
Organise versioned controllers in packages — controller.v1, controller.v2 — to keep the codebase navigable as the number of versions grows. Share service and repository layers across all versions.
Encode the version in the Accept media type: Accept: application/vnd.example.v2+json. The most RESTful approach (leverages HTTP as designed), but harder for clients and breaks browser-based testing.
@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
For larger APIs with many versioned endpoints, a custom annotation + RequestMappingHandlerMapping reduces boilerplate. Every controller annotated with @ApiVersion("v2") automatically gets the URI prefix.
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) { ... }
}
Add the Deprecation and Sunset response headers (RFC 8594) to old versions so clients are warned programmatically.
@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");
}
}
}
| 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/) for public APIs. It is the most understood approach across API consumers, works out of the box with CDNs and OpenAPI tooling, and requires zero client sophistication.