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: true and Sunset headers 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.