API Versioning in Spring Boot 4
APIs evolve. Adding fields is safe — removing or changing fields breaks clients. Versioning gives you a path to evolve the API without breaking existing integrations.
When You Need Versioning
You need a new API version when:
- Removing a field from a response
- Changing a field’s type or semantics
- Changing URL structure significantly
- Breaking backward-incompatible business logic changes
You don’t need a new version for:
- Adding new optional fields (non-breaking)
- Adding new endpoints
- Bug fixes that don’t change the contract
Strategy 1: URL Path Versioning
The most common and explicit approach:
/api/v1/orders
/api/v2/orders
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
@GetMapping("/{id}")
public OrderResponseV1 getOrder(@PathVariable UUID id) {
return orderService.findByIdV1(id);
}
}
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
@GetMapping("/{id}")
public OrderResponseV2 getOrder(@PathVariable UUID id) {
return orderService.findByIdV2(id);
}
}
Pros: Simple, clear, easy to test, cacheable (URL includes version). Cons: Version in URL is considered “not RESTful” by purists; URL proliferation with many versions.
Strategy 2: Header Versioning
GET /api/orders/123
Accept: application/vnd.devopsmonk.v2+json
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping(value = "/{id}",
produces = "application/vnd.devopsmonk.v1+json")
public OrderResponseV1 getOrderV1(@PathVariable UUID id) {
return orderService.findByIdV1(id);
}
@GetMapping(value = "/{id}",
produces = "application/vnd.devopsmonk.v2+json")
public OrderResponseV2 getOrderV2(@PathVariable UUID id) {
return orderService.findByIdV2(id);
}
}
Or use a custom request header:
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public OrderResponseV1 getOrderV1(@PathVariable UUID id) { ... }
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public OrderResponseV2 getOrderV2(@PathVariable UUID id) { ... }
Pros: URL stays clean; version is metadata. Cons: Harder to test in browser; not as cacheable; easy to forget the header.
URL Versioning: Clean Package Structure
With URL versioning, organize code by version:
api/
├── v1/
│ ├── OrderControllerV1.java
│ ├── OrderResponseV1.java ← older response format
│ └── CreateOrderRequestV1.java
└── v2/
├── OrderControllerV2.java
├── OrderResponseV2.java ← new response format
└── CreateOrderRequestV2.java
// V1 response — older clients expect this
public record OrderResponseV1(
UUID id,
String status,
BigDecimal totalAmount,
List<OrderItemV1> items
) {}
// V2 response — new structure
public record OrderResponseV2(
UUID id,
OrderStatus status, // was String, now enum
Money totalAmount, // was BigDecimal, now Money record
List<OrderItemV2> items,
CustomerReference customer, // new field
Instant createdAt // new field
) {}
// Service handles both versions
@Service
public class OrderService {
public OrderResponseV1 findByIdV1(UUID id) {
Order order = findOrder(id);
return new OrderResponseV1(
order.getId(),
order.getStatus().name(), // enum → String for V1
order.getTotalAmount(),
order.getItems().stream().map(OrderItemV1::from).toList()
);
}
public OrderResponseV2 findByIdV2(UUID id) {
Order order = findOrder(id);
return new OrderResponseV2(
order.getId(),
order.getStatus(), // enum directly for V2
new Money(order.getTotalAmount(), "USD"),
order.getItems().stream().map(OrderItemV2::from).toList(),
new CustomerReference(order.getCustomerId()),
order.getCreatedAt()
);
}
private Order findOrder(UUID id) {
return orderRepository.findById(id).orElseThrow(() ->
new OrderNotFoundException(id));
}
}
Default Version Strategy
Always route to the latest version when no version is specified, or the lowest (most stable) version — decide and document it:
// Option A: default to latest
@GetMapping("/api/orders/{id}")
public OrderResponseV2 getOrderDefault(@PathVariable UUID id) {
return orderService.findByIdV2(id);
}
// Option B: require version — no unversioned endpoint
// Return 400 if no version specified
Most public APIs default to v1 (most stable) for backward compatibility. Internal APIs default to latest.
Version Deprecation
Signal deprecated versions via response headers:
@RestController
@RequestMapping("/api/v1/orders")
@Deprecated
public class OrderControllerV1 {
@GetMapping("/{id}")
public ResponseEntity<OrderResponseV1> getOrder(
@PathVariable UUID id,
HttpServletResponse response) {
// Signal deprecation in every response
response.addHeader("Deprecation", "true");
response.addHeader("Sunset", "Sat, 31 Dec 2026 23:59:59 GMT");
response.addHeader("Link", "</api/v2/orders/{id}>; rel=\"successor-version\"");
return ResponseEntity.ok(orderService.findByIdV1(id));
}
}
Clients see Deprecation: true and Sunset headers — they know when v1 goes away.
API Version Metadata
Document versions in OpenAPI:
@Configuration
public class OpenApiConfig {
@Bean
public GroupedOpenApi v1Api() {
return GroupedOpenApi.builder()
.group("v1")
.displayName("Order API v1 (deprecated)")
.pathsToMatch("/api/v1/**")
.build();
}
@Bean
public GroupedOpenApi v2Api() {
return GroupedOpenApi.builder()
.group("v2")
.displayName("Order API v2 (current)")
.pathsToMatch("/api/v2/**")
.build();
}
}
Swagger UI shows separate tabs for v1 and v2.
Testing Multiple Versions
@WebMvcTest(controllers = {OrderControllerV1.class, OrderControllerV2.class})
class OrderApiVersioningTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
void v1ReturnsStatusAsString() throws Exception {
when(orderService.findByIdV1(ORDER_ID))
.thenReturn(new OrderResponseV1(ORDER_ID, "PENDING", BigDecimal.TEN, List.of()));
mockMvc.perform(get("/api/v1/orders/{id}", ORDER_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").isString())
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void v2ReturnsStructuredResponse() throws Exception {
when(orderService.findByIdV2(ORDER_ID))
.thenReturn(new OrderResponseV2(ORDER_ID, OrderStatus.PENDING,
new Money(BigDecimal.TEN, "USD"), List.of(),
new CustomerReference(CUSTOMER_ID), Instant.now()));
mockMvc.perform(get("/api/v2/orders/{id}", ORDER_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalAmount.amount").value(10))
.andExpect(jsonPath("$.totalAmount.currency").value("USD"))
.andExpect(jsonPath("$.customer.id").value(CUSTOMER_ID.toString()));
}
@Test
void v1IncludesDeprecationHeader() throws Exception {
when(orderService.findByIdV1(any())).thenReturn(v1Response());
mockMvc.perform(get("/api/v1/orders/{id}", ORDER_ID))
.andExpect(header().string("Deprecation", "true"))
.andExpect(header().exists("Sunset"));
}
}
Backward Compatibility Without Versioning
Before reaching for versioning, try backward-compatible changes:
// Adding a new optional field — no version bump needed
public record OrderResponseV2(
UUID id,
OrderStatus status,
BigDecimal totalAmount,
List<OrderItem> items,
@JsonInclude(Include.NON_NULL) String couponCode, // null for old orders
@JsonInclude(Include.NON_NULL) Instant updatedAt // null if never updated
) {}
Clients that don’t know about couponCode ignore it. New clients use it. No version bump.
// Accepting unknown fields — clients tolerate new request fields
@JsonIgnoreProperties(ignoreUnknown = true)
public record CreateOrderRequest(
UUID customerId,
List<OrderItemRequest> items
// Future fields ignored by old clients reading this type
) {}
Configure Jackson globally:
spring:
jackson:
deserialization:
fail-on-unknown-properties: false # ignore unknown fields in requests
What You’ve Learned
- Version when you make breaking changes: removing fields, changing types, changing semantics
- URL versioning (
/api/v1/,/api/v2/) is explicit and cacheable — the most practical choice - Header versioning (
Accept: application/vnd.example.v2+json) keeps URLs clean but harder to discover - Add
Deprecation: trueandSunsetheaders to responses from deprecated versions - Test both versions explicitly — verify the response structure differs as expected
- Before versioning, try backward-compatible changes: optional new fields,
@JsonIgnoreProperties
Next: Article 59 — Spring AI: Build a RAG Application — the final article in the series.