Contents

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.

Route by a custom header such as X-API-Version: 2. Keeps URLs clean and stable; URLs can be bookmarked and shared without the version leaking into them.

@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

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"); } } }
AspectURI PathCustom HeaderAccept 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 commonModerateLess 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.