API versioning is crucial for maintaining backward compatibility while evolving your APIs. Choosing the right versioning strategy ensures smooth transitions for API consumers and prevents breaking changes from impacting existing clients.
Why API Versioning Matters
- Backward Compatibility: Prevent breaking existing integrations
- Controlled Evolution: Introduce new features without disrupting current users
- Clear Communication: Signal breaking changes to API consumers
- Parallel Development: Support multiple API versions simultaneously
Common Versioning Strategies
1. URI Path Versioning
Most common approach where version is included in the URL path.
2. Query Parameter Versioning
Version specified as a query parameter.
3. Header Versioning
Version specified in HTTP headers.
4. Media Type Versioning (Content Negotiation)
Version specified in Accept header.
Implementation Examples
Strategy 1: URI Path Versioning
Example 1.1: Spring Boot with Path Variables
@RestController
@RequestMapping("/api/v{version}")
public class UserControllerPathVersioning {
// Version 1 - Simple user response
@GetMapping(value = "/users/{id}", params = "version=1")
public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
UserV1 user = new UserV1(id, "John Doe", "[email protected]");
return ResponseEntity.ok(user);
}
// Version 2 - Enhanced user response with address
@GetMapping(value = "/users/{id}", params = "version=2")
public ResponseEntity<UserV2> getUserV2(@PathVariable Long id) {
UserV2 user = new UserV2(
id,
"John Doe",
"[email protected]",
new Address("123 Main St", "New York", "NY")
);
return ResponseEntity.ok(user);
}
// Data transfer objects
public static class UserV1 {
private Long id;
private String name;
private String email;
public UserV1(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
// getters and setters
}
public static class UserV2 {
private Long id;
private String name;
private String email;
private Address address;
public UserV2(Long id, String name, String email, Address address) {
this.id = id;
this.name = name;
this.email = email;
this.address = address;
}
// getters and setters
}
public static class Address {
private String street;
private String city;
private String state;
public Address(String street, String city, String state) {
this.street = street;
this.city = city;
this.state = state;
}
// getters and setters
}
}
Example 1.2: Package-based Versioning
// Package structure:
// com.example.api.v1.UserController
// com.example.api.v2.UserController
// v1 Package
package com.example.api.v1;
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public ResponseEntity<UserV1> getUser(@PathVariable Long id) {
UserV1 user = new UserV1(id, "John Doe", "[email protected]");
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<UserV1> createUser(@RequestBody UserV1 user) {
// Business logic for v1
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
public static class UserV1 {
private Long id;
private String name;
private String email;
// constructors, getters, setters
}
}
// v2 Package
package com.example.api.v2;
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public ResponseEntity<UserV2> getUser(@PathVariable Long id) {
UserV2 user = new UserV2(
id, "John Doe", "[email protected]",
new Address("123 Main St", "New York", "NY")
);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<UserV2> createUser(@RequestBody UserV2 user) {
// Enhanced business logic for v2
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
public static class UserV2 {
private Long id;
private String name;
private String email;
private Address address;
// constructors, getters, setters
}
public static class Address {
private String street;
private String city;
private String state;
// constructors, getters, setters
}
}
Strategy 2: Query Parameter Versioning
Example 2.1: Spring Boot with Request Parameters
@RestController
@RequestMapping("/api/users")
public class UserControllerQueryVersioning {
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@RequestParam(defaultValue = "1") String version) {
return switch (version) {
case "1" -> ResponseEntity.ok(getUserV1(id));
case "2" -> ResponseEntity.ok(getUserV2(id));
default -> ResponseEntity.badRequest()
.body(new ErrorResponse("Unsupported version: " + version));
};
}
private UserV1 getUserV1(Long id) {
return new UserV1(id, "John Doe", "[email protected]");
}
private UserV2 getUserV2(Long id) {
return new UserV2(
id, "John Doe", "[email protected]",
new Address("123 Main St", "New York", "NY")
);
}
// Data classes
public static class UserV1 { /* ... */ }
public static class UserV2 { /* ... */ }
public static class Address { /* ... */ }
public static class ErrorResponse {
private String error;
private LocalDateTime timestamp;
public ErrorResponse(String error) {
this.error = error;
this.timestamp = LocalDateTime.now();
}
// getters and setters
}
}
Strategy 3: Header Versioning
Example 3.1: Custom Header Versioning
@RestController
@RequestMapping("/api/users")
public class UserControllerHeaderVersioning {
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@RequestHeader(value = "X-API-Version", defaultValue = "1") String version) {
return switch (version) {
case "1" -> ResponseEntity.ok(getUserV1(id));
case "2" -> ResponseEntity.ok(getUserV2(id));
default -> ResponseEntity.badRequest()
.body(new ErrorResponse("Unsupported API version: " + version));
};
}
// Alternative using produces
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
return ResponseEntity.ok(new UserV1(id, "John V1", "[email protected]"));
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public ResponseEntity<UserV2> getUserV2(@PathVariable Long id) {
return ResponseEntity.ok(new UserV2(id, "John V2", "[email protected]"));
}
}
Strategy 4: Media Type Versioning (Content Negotiation)
Example 4.1: Accept Header Versioning
@RestController
@RequestMapping("/api/users")
public class UserControllerMediaTypeVersioning {
@GetMapping(value = "/{id}", produces = "application/vnd.company.app-v1+json")
public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
return ResponseEntity.ok(new UserV1(id, "John V1", "[email protected]"));
}
@GetMapping(value = "/{id}", produces = "application/vnd.company.app-v2+json")
public ResponseEntity<UserV2> getUserV2(@PathVariable Long id) {
return ResponseEntity.ok(new UserV2(
id, "John V2", "[email protected]",
new Address("123 Main St", "NYC", "NY")
));
}
// Generic endpoint with content negotiation
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@RequestHeader(value = "Accept", defaultValue = "application/vnd.company.app-v1+json") String acceptHeader) {
if (acceptHeader.contains("vnd.company.app-v2")) {
return ResponseEntity.ok(getUserV2(id));
} else {
return ResponseEntity.ok(getUserV1(id));
}
}
}
Advanced Versioning Patterns
Pattern 1: Versioning with Custom Annotations
Example 5.1: Custom Version Annotation
// Custom annotation
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
String value();
}
// Version resolver
@Component
public class ApiVersionResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(String.class) &&
parameter.hasParameterAnnotation(ApiVersion.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
// Check header first, then query param, then default
String headerVersion = webRequest.getHeader("X-API-Version");
if (headerVersion != null) {
return headerVersion;
}
String queryVersion = webRequest.getParameter("version");
if (queryVersion != null) {
return queryVersion;
}
ApiVersion annotation = parameter.getParameterAnnotation(ApiVersion.class);
return annotation != null ? annotation.value() : "1";
}
}
// Configuration
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new ApiVersionResolver());
}
}
// Usage in controller
@RestController
@RequestMapping("/api/users")
public class UserControllerAnnotationVersioning {
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@ApiVersion("1") String version) {
return switch (version) {
case "1" -> ResponseEntity.ok(getUserV1(id));
case "2" -> ResponseEntity.ok(getUserV2(id));
default -> ResponseEntity.badRequest().build();
};
}
}
Pattern 2: Feature Toggle Versioning
Example 6.1: Feature-based Versioning
@RestController
@RequestMapping("/api/users")
public class UserControllerFeatureVersioning {
@Autowired
private FeatureToggleService featureToggle;
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
if (featureToggle.isEnabled("user-v2-feature")) {
return ResponseEntity.ok(getUserV2(id));
} else {
return ResponseEntity.ok(getUserV1(id));
}
}
@PostMapping
public ResponseEntity<?> createUser(@RequestBody Map<String, Object> userData) {
String version = featureToggle.getVersion("user-creation");
return switch (version) {
case "v2" -> createUserV2(userData);
case "v1" -> createUserV1(userData);
default -> ResponseEntity.badRequest().build();
};
}
private ResponseEntity<?> createUserV1(Map<String, Object> userData) {
// v1 creation logic
return ResponseEntity.ok().build();
}
private ResponseEntity<?> createUserV2(Map<String, Object> userData) {
// v2 creation logic
return ResponseEntity.ok().build();
}
}
@Service
public class FeatureToggleService {
private final Map<String, Boolean> features = new ConcurrentHashMap<>();
private final Map<String, String> versions = new ConcurrentHashMap<>();
public FeatureToggleService() {
// Initialize from configuration or feature flag service
features.put("user-v2-feature", true);
versions.put("user-creation", "v2");
}
public boolean isEnabled(String feature) {
return features.getOrDefault(feature, false);
}
public String getVersion(String feature) {
return versions.getOrDefault(feature, "v1");
}
}
Pattern 3: Database-driven Versioning
Example 7.1: Client-specific Versioning
@Service
public class ApiVersionService {
@Autowired
private ClientVersionRepository clientVersionRepository;
public String getClientVersion(String clientId, String endpoint) {
return clientVersionRepository.findByClientIdAndEndpoint(clientId, endpoint)
.map(ClientVersion::getVersion)
.orElse("v1"); // default version
}
}
@Entity
@Table(name = "client_versions")
public class ClientVersion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String clientId;
private String endpoint;
private String version;
private LocalDateTime createdAt;
// constructors, getters, setters
}
@RestController
@RequestMapping("/api/users")
public class UserControllerClientVersioning {
@Autowired
private ApiVersionService versionService;
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@RequestHeader("X-Client-ID") String clientId) {
String version = versionService.getClientVersion(clientId, "getUser");
return switch (version) {
case "v1" -> ResponseEntity.ok(getUserV1(id));
case "v2" -> ResponseEntity.ok(getUserV2(id));
default -> ResponseEntity.badRequest().build();
};
}
}
Best Practices and Configuration
Versioning Strategy Configuration
@Configuration
public class ApiVersionConfig {
@Bean
public RequestMappingHandlerMapping requestMappingHandlerMapping() {
return new CustomRequestMappingHandlerMapping();
}
// Custom handler mapping for versioning
public static class CustomRequestMappingHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(Class<?> beanType) {
return super.isHandler(beanType) &&
beanType.isAnnotationPresent(RestController.class);
}
}
}
// Global versioning with filters
@Component
public class ApiVersionFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String version = extractVersion(httpRequest);
httpRequest.setAttribute("apiVersion", version);
// Validate version
if (!isValidVersion(version)) {
sendErrorResponse(httpResponse, "Unsupported API version");
return;
}
chain.doFilter(request, response);
}
private String extractVersion(HttpServletRequest request) {
// Extract from path, header, or parameter
String path = request.getRequestURI();
if (path.contains("/v1/")) return "1";
if (path.contains("/v2/")) return "2";
String headerVersion = request.getHeader("X-API-Version");
if (headerVersion != null) return headerVersion;
return request.getParameter("version") != null ?
request.getParameter("version") : "1";
}
private boolean isValidVersion(String version) {
return Arrays.asList("1", "2", "3").contains(version);
}
private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.setContentType("application/json");
ErrorResponse error = new ErrorResponse(message, LocalDateTime.now());
String jsonResponse = new ObjectMapper().writeValueAsString(error);
response.getWriter().write(jsonResponse);
}
}
Deprecation and Sunset Headers
@RestController
public class DeprecatedVersionController {
@GetMapping("/api/v1/deprecated-endpoint")
public ResponseEntity<String> deprecatedEndpoint(HttpServletResponse response) {
// Add deprecation headers
response.setHeader("Deprecation", "true");
response.setHeader("Sunset", "Tue, 31 Dec 2024 23:59:59 GMT");
response.setHeader("Link", "</api/v2/new-endpoint>; rel=\"successor-version\"");
return ResponseEntity.ok("This endpoint is deprecated. Please use v2.");
}
}
Comparison of Versioning Strategies
| Strategy | Pros | Cons | Use Cases |
|---|---|---|---|
| URI Path | Simple, clear, cacheable | URL pollution, breaking browser caching | Public APIs, RESTful services |
| Query Parameter | Flexible, no URL changes | Complex caching, less visible | Internal APIs, gradual migration |
| Headers | Clean URLs, flexible | Less discoverable, requires client cooperation | Mobile apps, internal services |
| Media Type | Standards-compliant, explicit | Complex, less common | Hypermedia APIs, content negotiation |
Migration Strategy
@Service
public class ApiMigrationService {
public void migrateUserData(UserV1 v1User) {
// Convert v1 to v2
UserV2 v2User = convertV1ToV2(v1User);
// Save to both versions during transition
userRepositoryV1.save(v1User);
userRepositoryV2.save(v2User);
}
private UserV2 convertV1ToV2(UserV1 v1User) {
// Conversion logic with default values
return new UserV2(
v1User.getId(),
v1User.getName(),
v1User.getEmail(),
new Address("Unknown", "Unknown", "Unknown")
);
}
}
Conclusion
Key Recommendations:
- Choose Based on Requirements:
- Public APIs → URI Path versioning
- Internal APIs → Header or Query Parameter versioning
- Hypermedia APIs → Media Type versioning
- Maintain Consistency: Stick to one strategy across your API
- Document Thoroughly: Clearly document versioning strategy and changes
- Plan Deprecation: Provide ample notice before retiring versions
- Monitor Usage: Track version adoption to plan retirement
- Use Semantic Versioning: Consider MAJOR.MINOR.PATCH for breaking changes
Most Common Approach: URI Path versioning (/api/v1/resource) is widely adopted due to its simplicity, clarity, and excellent tooling support.
The right versioning strategy depends on your specific use case, client requirements, and long-term API evolution plans. Choose the approach that provides the best balance of simplicity, maintainability, and client experience for your specific context.