The most surprising thing about API versioning is that you don’t have to version your API at all, at least not in the way most people think.
Let’s see it in action. Imagine a simple ProductController in Spring Boot.
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping
public ResponseEntity<List<Product>> getAllProducts() {
// ... logic to fetch all products
return ResponseEntity.ok(productService.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<Product> getProductById(@PathVariable Long id) {
// ... logic to fetch a single product by ID
return ResponseEntity.ok(productService.findById(id));
}
}
Right now, there’s no versioning. GET /api/products and GET /api/products/123 will always return the current implementation. If we need to make a breaking change, like renaming name to productName or removing a field, we could introduce a new version.
URI Versioning
This is the most straightforward approach. You embed the version directly into the URL path.
Example:
GET /api/v1/productsGET /api/v2/products
In Spring Boot, you’d modify your @RequestMapping annotations:
// Version 1
@RestController
@RequestMapping("/api/v1/products")
public class ProductControllerV1 {
// ... v1 endpoints
}
// Version 2
@RestController
@RequestMapping("/api/v2/products")
public class ProductControllerV2 {
// ... v2 endpoints with breaking changes
}
Pros:
- Extremely clear and easy to understand.
- Simple to implement.
Cons:
- Can lead to URL bloat.
- Difficult to manage if you have many versions.
- Doesn’t align with RESTful principles of resource identification (the resource itself doesn’t change, only its representation).
Header Versioning
Instead of the URI, you use a custom HTTP header to specify the version. This keeps your URIs cleaner.
Example:
GET /api/productswithX-API-Version: 1headerGET /api/productswithX-API-Version: 2header
In Spring Boot, you’d typically use a HandlerInterceptor or a more sophisticated approach with custom request mappings. A simpler way to illustrate is by using @RequestMapping with headers:
@RestController
@RequestMapping(value = "/api/products", headers = "X-API-Version=1")
public class ProductControllerV1 {
// ... v1 endpoints
}
@RestController
@RequestMapping(value = "/api/products", headers = "X-API-Version=2")
public class ProductControllerV2 {
// ... v2 endpoints
}
Pros:
- Keeps URIs clean.
- Separates versioning from resource identification.
Cons:
- Less discoverable than URI versioning (clients need to know which headers to send).
- Can be harder to test directly in browsers without tools.
Media Type Versioning (Content Negotiation)
This is often considered the most RESTful approach. You use the Accept header with a custom media type (MIME type) that includes the version.
Example:
GET /api/productswithAccept: application/vnd.myapp.v1+jsonGET /api/productswithAccept: application/vnd.myapp.v2+json
In Spring Boot, you can leverage Spring’s content negotiation capabilities:
@RestController
@RequestMapping(value = "/api/products", produces = "application/vnd.myapp.v1+json")
public class ProductControllerV1 {
// ... v1 endpoints
}
@RestController
@RequestMapping(value = "/api/products", produces = "application/vnd.myapp.v2+json")
public class ProductControllerV2 {
// ... v2 endpoints
}
Pros:
- Most RESTful approach.
- Leverages standard HTTP mechanisms.
- Keeps URIs clean.
Cons:
- Can be more complex to set up and understand.
- Requires clients to correctly set the
Acceptheader. - Doesn’t allow for changes in request body content type between versions easily, only response.
The "No Versioning" Approach (Backward Compatibility)
The real secret to API versioning is often achieving backward compatibility and avoiding explicit versioning altogether. If you can make breaking changes without breaking existing clients, you don’t need new versions. This involves careful design, adding new optional fields rather than renaming or removing existing ones, and evolving your API incrementally.
Consider this scenario: you want to add a description field to your Product object.
Initial Product:
public class Product {
private Long id;
private String name;
private BigDecimal price;
// ... getters and setters
}
Evolved Product (for V2, but still compatible with V1 clients):
public class Product {
private Long id;
private String name;
private BigDecimal price;
private String description; // New optional field
// ... getters and setters
}
If your V1 clients only expect id, name, and price, they will simply ignore the description field when it’s returned. When you later require a description or change its format, that’s when you might consider a new version or a different strategy. The key is that existing clients continue to function.
This is achieved by adhering to strict contracts, often using libraries that enforce schema evolution, and by treating existing fields as immutable contracts. Any new functionality is additive. The framework doesn’t inherently know about your "versions" unless you tell it to via the mapping strategies above. You build the versioning logic into your application’s structure.
The next step is understanding how to handle API Gateway integration for versioned APIs, or how to manage the lifecycle of older API versions.