AP
Versioning
Api Design core v1.0.0
API Versioning
Overview
API versioning enables evolving APIs while maintaining backward compatibility. This skill covers versioning strategies, breaking vs non-breaking changes, deprecation, and migration patterns.
Key Concepts
Versioning Strategies
┌─────────────────────────────────────────────────────────────┐
│ API Versioning Strategies │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. URL Path Versioning: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ /api/v1/users │ │
│ │ /api/v2/users │ │
│ │ │ │
│ │ ✓ Clear and explicit │ │
│ │ ✓ Easy to route │ │
│ │ ✗ URI changes between versions │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 2. Query Parameter Versioning: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ /api/users?version=1 │ │
│ │ /api/users?version=2 │ │
│ │ │ │
│ │ ✓ Same URI │ │
│ │ ✓ Optional versioning │ │
│ │ ✗ Easy to miss in requests │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 3. Header Versioning: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ X-API-Version: 1 │ │
│ │ Accept-Version: 2 │ │
│ │ │ │
│ │ ✓ Clean URIs │ │
│ │ ✓ Follows HTTP conventions │ │
│ │ ✗ Harder to test in browser │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 4. Content Negotiation (Media Type): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Accept: application/vnd.company.users.v1+json │ │
│ │ Accept: application/vnd.company.users.v2+json │ │
│ │ │ │
│ │ ✓ Most RESTful approach │ │
│ │ ✓ Version per resource possible │ │
│ │ ✗ More complex to implement │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Breaking vs Non-Breaking Changes: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BREAKING (requires new version): │ │
│ │ • Removing fields │ │
│ │ • Changing field types │ │
│ │ • Renaming fields │ │
│ │ • Changing required/optional │ │
│ │ • Changing behavior │ │
│ │ │ │
│ │ NON-BREAKING (same version): │ │
│ │ • Adding new optional fields │ │
│ │ • Adding new endpoints │ │
│ │ • Adding new enum values │ │
│ │ • Relaxing validation rules │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Best Practices
1. Version from Day One
Start with v1 even before you need v2.
2. Minimize Breaking Changes
Prefer additive changes.
3. Maintain Multiple Versions Temporarily
Support N-1 or N-2 versions.
4. Deprecate Before Removing
Give clients migration time.
5. Document Changes Clearly
Maintain changelog for each version.
Code Examples
Example 1: URL Path Versioning
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public ResponseEntity<UserV1Response> getUser(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok(UserV1Response.from(user));
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public ResponseEntity<UserV2Response> getUser(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok(UserV2Response.from(user));
}
}
// V1 Response - original format
@Data
class UserV1Response {
private Long id;
private String name; // Full name as single field
private String email;
public static UserV1Response from(User user) {
UserV1Response response = new UserV1Response();
response.id = user.getId();
response.name = user.getFirstName() + " " + user.getLastName();
response.email = user.getEmail();
return response;
}
}
// V2 Response - improved format
@Data
class UserV2Response {
private Long id;
private String firstName; // Split name into components
private String lastName;
private String email;
private String phoneNumber; // New field
private AddressResponse address; // New nested object
public static UserV2Response from(User user) {
UserV2Response response = new UserV2Response();
response.id = user.getId();
response.firstName = user.getFirstName();
response.lastName = user.getLastName();
response.email = user.getEmail();
response.phoneNumber = user.getPhoneNumber();
response.address = AddressResponse.from(user.getAddress());
return response;
}
}
Example 2: Header-Based Versioning
@RestController
@RequestMapping("/api/users")
public class VersionedUserController {
private final Map<String, UserResponseMapper> mappers = Map.of(
"1", UserV1Response::from,
"2", UserV2Response::from
);
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@RequestHeader(value = "X-API-Version", defaultValue = "2") String version) {
UserResponseMapper mapper = mappers.get(version);
if (mapper == null) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("Unsupported API version: " + version));
}
User user = userService.findById(id);
return ResponseEntity.ok(mapper.map(user));
}
}
// Version resolver component
@Component
public class ApiVersionResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(ApiVersion.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
String version = webRequest.getHeader("X-API-Version");
if (version == null) {
version = webRequest.getParameter("version");
}
if (version == null) {
ApiVersion annotation = parameter.getParameterAnnotation(ApiVersion.class);
version = annotation.defaultValue();
}
return Integer.parseInt(version);
}
}
// Usage with annotation
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@ApiVersion(defaultValue = "2") int version) {
// Use version to select appropriate response format
}
Example 3: Content Negotiation Versioning
@RestController
@RequestMapping("/api/users")
public class MediaTypeVersionedController {
private static final String V1_MEDIA_TYPE = "application/vnd.company.users.v1+json";
private static final String V2_MEDIA_TYPE = "application/vnd.company.users.v2+json";
@GetMapping(value = "/{id}", produces = V1_MEDIA_TYPE)
public ResponseEntity<UserV1Response> getUserV1(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(V1_MEDIA_TYPE))
.body(UserV1Response.from(user));
}
@GetMapping(value = "/{id}", produces = V2_MEDIA_TYPE)
public ResponseEntity<UserV2Response> getUserV2(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(V2_MEDIA_TYPE))
.body(UserV2Response.from(user));
}
// Default to latest version for standard JSON accept header
@GetMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UserV2Response> getUserDefault(@PathVariable Long id) {
return getUserV2(id);
}
}
// Custom media type configuration
@Configuration
public class MediaTypeConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.defaultContentType(MediaType.APPLICATION_JSON)
.mediaType("v1", MediaType.parseMediaType("application/vnd.company.users.v1+json"))
.mediaType("v2", MediaType.parseMediaType("application/vnd.company.users.v2+json"));
}
}
Example 4: Deprecation Strategy
@RestController
@RequestMapping("/api/v1/orders")
@Deprecated(since = "2024-01-01", forRemoval = true)
public class OrderControllerV1 {
@GetMapping("/{id}")
public ResponseEntity<OrderV1Response> getOrder(@PathVariable String id) {
// Add deprecation warning header
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "Sat, 01 Jul 2025 00:00:00 GMT")
.header("Link", "</api/v2/orders/" + id + ">; rel=\"successor-version\"")
.body(orderService.findByIdV1(id));
}
}
// Deprecation filter for all v1 endpoints
@Component
public class DeprecationFilter extends OncePerRequestFilter {
private final Map<String, DeprecationInfo> deprecatedPaths = Map.of(
"/api/v1/", new DeprecationInfo(
LocalDate.of(2025, 7, 1),
"API v1 is deprecated. Please migrate to v2."
)
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
deprecatedPaths.entrySet().stream()
.filter(e -> path.startsWith(e.getKey()))
.findFirst()
.ifPresent(e -> {
DeprecationInfo info = e.getValue();
response.setHeader("Deprecation", "true");
response.setHeader("Sunset", info.sunsetDate.format(DateTimeFormatter.RFC_1123_DATE_TIME));
response.setHeader("X-Deprecation-Notice", info.message);
// Log deprecation usage for monitoring
log.warn("Deprecated API accessed: {} by client {}",
path, request.getHeader("X-Client-ID"));
});
filterChain.doFilter(request, response);
}
}
// Migration guide response
@RestController
@RequestMapping("/api/migration")
public class MigrationController {
@GetMapping("/v1-to-v2")
public ResponseEntity<MigrationGuide> getMigrationGuide() {
return ResponseEntity.ok(new MigrationGuide(
"v1", "v2",
List.of(
new ChangeDescription(
"User.name split into firstName and lastName",
"GET /users/{id}",
"name", "firstName, lastName"
),
new ChangeDescription(
"Added address field to user response",
"GET /users/{id}",
null, "address"
)
),
LocalDate.of(2025, 7, 1)
));
}
}
Example 5: Version Transformation Layer
@Service
public class VersionTransformationService {
private final Map<VersionPair, BiFunction<Object, Class<?>, Object>> transformers = new HashMap<>();
@PostConstruct
public void registerTransformers() {
// Register transformers between versions
transformers.put(
new VersionPair(1, 2, UserResponse.class),
(source, targetClass) -> transformUserV1ToV2((UserV1Response) source)
);
transformers.put(
new VersionPair(2, 1, UserResponse.class),
(source, targetClass) -> transformUserV2ToV1((UserV2Response) source)
);
}
@SuppressWarnings("unchecked")
public <T> T transform(Object source, int sourceVersion, int targetVersion, Class<T> targetClass) {
if (sourceVersion == targetVersion) {
return (T) source;
}
VersionPair key = new VersionPair(sourceVersion, targetVersion, source.getClass());
BiFunction<Object, Class<?>, Object> transformer = transformers.get(key);
if (transformer == null) {
throw new UnsupportedVersionTransformationException(
"No transformer for " + sourceVersion + " to " + targetVersion
);
}
return (T) transformer.apply(source, targetClass);
}
private UserV2Response transformUserV1ToV2(UserV1Response v1) {
UserV2Response v2 = new UserV2Response();
// Split name into first/last
String[] nameParts = v1.getName().split(" ", 2);
v2.setFirstName(nameParts[0]);
v2.setLastName(nameParts.length > 1 ? nameParts[1] : "");
v2.setId(v1.getId());
v2.setEmail(v1.getEmail());
// New fields default to null
v2.setPhoneNumber(null);
v2.setAddress(null);
return v2;
}
private UserV1Response transformUserV2ToV1(UserV2Response v2) {
UserV1Response v1 = new UserV1Response();
// Combine first/last into name
v1.setName(v2.getFirstName() + " " + v2.getLastName());
v1.setId(v2.getId());
v1.setEmail(v2.getEmail());
// V2-only fields are lost
return v1;
}
record VersionPair(int sourceVersion, int targetVersion, Class<?> resourceType) {}
}
// Unified controller using transformation
@RestController
@RequestMapping("/api/users")
public class UnifiedUserController {
private final UserService userService;
private final VersionTransformationService transformer;
@GetMapping("/{id}")
public ResponseEntity<?> getUser(
@PathVariable Long id,
@RequestHeader(value = "X-API-Version", defaultValue = "2") int requestedVersion) {
// Always fetch latest internal representation
User user = userService.findById(id);
// Transform to latest response version
UserV2Response latestResponse = UserV2Response.from(user);
// Transform to requested version if different
Object response = transformer.transform(latestResponse, 2, requestedVersion, Object.class);
return ResponseEntity.ok(response);
}
}
Anti-Patterns
❌ No Versioning Strategy
Adding version after breaking clients is too late.
❌ Too Many Active Versions
Maintaining more than 2-3 versions is expensive.
// WRONG - no deprecation timeline
// v1, v2, v3, v4 all active indefinitely
// ✅ CORRECT - clear lifecycle
// v1: deprecated, sunset in 6 months
// v2: current stable
// v3: beta preview