Spring Boot 4 Native API Versioning: Moving Beyond /v1, /v2 URL Paths
The author demonstrates how Spring Boot 4's built-in API versioning via Spring MVC eliminates duplicated controller hierarchies by using header-based version routing, semantic version matching, and default version fallbacks, allowing per-endpoint versioning without copying unchanged endpoints.
While reorganizing a Spring Boot 4 project, the author discovered that Spring Framework 7 now includes native API versioning support directly in Spring MVC. Previously, API versioning was handled manually — typically by embedding version numbers in URL paths (e.g., /api/v1/orders, /api/v2/orders) or reading a version header and dispatching via custom logic.
The Old Way: URL Path Versioning
The article illustrates the traditional approach with an order query endpoint. The first version used a simple controller:
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderV1Controller {
private final OrderService orderService;
@GetMapping("/{orderId}")
public OrderVO getOrder(@PathVariable Long orderId) {
Order order = orderService.getOrder(orderId);
return new OrderVO(
order.getId(),
order.getOrderNo(),
order.getAmount(),
order.getStatus()
);
}
}When a major app redesign required new fields (discount amount, pay amount, logistics status) and a finer-grained order status, the team could not force all users to upgrade immediately. They added a second controller under /api/v2/orders:
@RestController
@RequestMapping("/api/v2/orders")
@RequiredArgsConstructor
public class OrderV2Controller {
private final OrderService orderService;
@GetMapping("/{orderId}")
public OrderV2VO getOrder(@PathVariable Long orderId) {
OrderDetail detail = orderService.getOrderDetail(orderId);
return new OrderV2VO(
detail.getId(),
detail.getOrderNo(),
detail.getOriginalAmount(),
detail.getDiscountAmount(),
detail.getPayAmount(),
detail.getOrderStatus(),
detail.getLogisticsStatus()
);
}
}Problems with Controller Duplication
As more versions and domains (orders, products, members, coupons) accumulated, the controller package structure became a deep versioned tree:
controller
├── v1
│ ├── OrderController
│ ├── ProductController
│ ├── MemberController
│ └── CouponController
├── v2
│ ├── OrderController
│ ├── ProductController
│ ├── MemberController
│ └── CouponController
└── v3
├── OrderController
└── ProductControllerMany endpoints did not actually change between versions (e.g., order logistics query remained identical), yet they were duplicated across version folders just to keep the URL path consistent. This led to massive code redundancy and maintenance burden.
Spring Boot 4's Native API Versioning
Spring MVC now allows configuring the version source via configuration. The author switched from URL paths to a custom header:
spring:
mvc:
apiversion:
use:
header: X-API-Version
default: 1.0Requests now use a stable resource URL: GET /api/orders/10001 Version 1 clients send X-API-Version: 1.0; version 2 clients send X-API-Version: 2.0. The controller declares versions on individual handler methods:
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping(
value = "/{orderId}",
version = "1.0"
)
public OrderV1VO getOrderV1(@PathVariable Long orderId) {
Order order = orderService.getOrder(orderId);
return new OrderV1VO(
order.getId(),
order.getOrderNo(),
order.getAmount(),
order.getStatus()
);
}
@GetMapping(
value = "/{orderId}",
version = "2.0"
)
public OrderV2VO getOrderV2(@PathVariable Long orderId) {
OrderDetail detail = orderService.getOrderDetail(orderId);
return new OrderV2VO(
detail.getId(),
detail.getOrderNo(),
detail.getOriginalAmount(),
detail.getDiscountAmount(),
detail.getPayAmount(),
detail.getOrderStatus(),
detail.getLogisticsStatus()
);
}
}Spring routes to the appropriate method based on the request's API version. Unchanged endpoints (like logistics query) need no version annotation and serve all versions:
@GetMapping("/{orderId}/logistics")
public LogisticsVO getLogistics(@PathVariable Long orderId) {
return orderService.queryLogistics(orderId);
}When a version-specific mapping exists, Spring prefers it; otherwise the unversioned method acts as a fallback.
DTO Separation Principle
The author emphasizes keeping DTOs separate per version to avoid the pitfall of a single DTO accumulating fields for multiple versions. Version 1 uses a record with four fields; version 2 uses a record with seven fields:
public record OrderV1VO(
Long id,
String orderNo,
BigDecimal amount,
String status
) {} public record OrderV2VO(
Long id,
String orderNo,
BigDecimal originalAmount,
BigDecimal discountAmount,
BigDecimal payAmount,
String orderStatus,
String logisticsStatus
) {}Sharing a single DTO and adding optional fields leads to ambiguity about which fields belong to which version and makes future removal risky.
Service Layer Independence
The service layer should remain version-agnostic. The OrderService returns a rich OrderDetail domain object containing all data needed by any version. The controller then maps this domain object to the version-specific DTO:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final LogisticsService logisticsService;
private final PromotionService promotionService;
public OrderDetail getOrderDetail(Long orderId) {
Order order = orderRepository
.findById(orderId)
.orElseThrow(OrderNotFoundException::new);
PromotionInfo promotion = promotionService.query(orderId);
LogisticsInfo logistics = logisticsService.query(orderId);
return OrderDetail.builder()
.id(order.getId())
.orderNo(order.getOrderNo())
.originalAmount(order.getOriginalAmount())
.discountAmount(promotion.discountAmount())
.payAmount(order.getPayAmount())
.orderStatus(order.getStatus())
.logisticsStatus(logistics.status())
.build();
}
}Controller conversion methods:
private OrderV1VO toV1(OrderDetail detail) {
return new OrderV1VO(
detail.getId(),
detail.getOrderNo(),
detail.getPayAmount(),
convertOldStatus(detail.getOrderStatus())
);
}
private OrderV2VO toV2(OrderDetail detail) {
return new OrderV2VO(
detail.getId(),
detail.getOrderNo(),
detail.getOriginalAmount(),
detail.getDiscountAmount(),
detail.getPayAmount(),
detail.getOrderStatus(),
detail.getLogisticsStatus()
);
}Creating versioned service classes ( OrderV1Service, OrderV2Service) is discouraged unless business rules truly diverge.
Semantic Version Ranges
Spring supports version ranges using a + suffix. For minor backward-compatible releases (2.0, 2.1, 2.2) that share the same response structure, a single method can handle all:
@GetMapping(
value = "/{orderId}",
version = "2.0+"
)
public OrderV2VO getOrderV2(@PathVariable Long orderId) {
return toV2(orderService.getOrderDetail(orderId));
}When a breaking change arrives (3.0), a new method with version = "3.0" is added. Spring parses versions semantically (major.minor.patch), so 1, 1.1, 1.1.2, 2.0 are all valid and comparable.
Version Parsing and Validation
This replaces fragile manual parsing logic like:
String version = request.getHeader("X-API-Version");
if (StringUtils.isBlank(version) || "1".equals(version)) {
return handleV1(request);
}
if ("2".equals(version)) {
return handleV2(request);
}
throw new UnsupportedApiVersionException(version);Now the controller simply declares version = "1.0" and the framework handles extraction, validation, and routing regardless of whether the version comes from a header, query parameter, or URL path segment.
Supported Versions Configuration
To reject unknown versions explicitly, the author configures a supported versions list:
spring:
mvc:
apiversion:
use:
header: X-API-Version
default: 1.0
supported:
- 1.0
- 2.0
- 2.1A request with X-API-Version: 9.9 will be rejected rather than falling through to an arbitrary handler.
Migration Strategy for Legacy Clients
Old clients that do not send the header continue to work because of the default: 1.0 setting. This provides a migration window until the legacy client base shrinks to an acceptable level.
Integration with @HttpExchange Client
The same versioning mechanism works on the client side. Using Spring's @HttpExchange interface, the client declares the version it expects:
@HttpExchange("/api/users")
public interface UserClient {
@GetExchange(
value = "/{userId}",
version = "2.0"
)
UserDTO getUser(@PathVariable Long userId);
}For RestClient, version header insertion can be centralized instead of added per request.
Gradual Migration Approach
The author does not delete all /v1, /v2 controllers at once. Stable, rarely-changed endpoints stay on the old scheme. Only endpoints actively needing new versions are migrated. Also, not all versioned methods are forced into a single controller; if version 3 introduces a completely different business flow, a separate controller or facade is still appropriate.
Conclusion
The real value is not merely removing /v1, /v2 from URLs, but eliminating the mechanical chore of copying entire controller hierarchies for every new version. Developers can now focus on the essential question: what actually changed between V1 and V2? Spring Boot 4 internalizes years of repetitive boilerplate — version extraction, validation, routing — into the framework, letting business logic remain the primary concern.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
