🏨 Spring Boot 4 — Native API Versioning Guide
(With Path, Header, Query & Media-Type Versioning)
📚 Table of Contents
- ℹ️ Description and Rationale
- 🛑 The Problem: Why “DTOs” Are Not Enough
- 🕰️ Before v/s After: The Evolution
- 🏁 Key Highlights
- ℹ️ API Versioning: Four Strategies (Introduction)
- ☑️ Step I — Configuring API Versioning
- Implementing WebMvcConfigurer
- Properties/YAML Configuration
- ☑️ Step II — Entities & Models
- 🏃♂️ Testing All Strategies
- 🙌 Bonus — Functional Endpoints
ℹ️ Description and Rationale
For years, Java developers have treated API versioning like the “awkward cousin” of REST design. We all knew we needed it, but Spring never gave us a standardized way to do it. Developers had to implement it manually using one of the common strategies (often inconsistently across teams or projects). We wrote custom interceptors, hacked URL paths manually, or cluttered our code with @RequestMapping(“/v1/…”), @RequestMapping(“/v2/…”), etc. That changes now.
With the arrival of Spring Boot 4.0, API versioning is a first-class citizen. It is native, declarative, and surprisingly powerful.
This guide explores handling this using a simple Hospitality Management App — my current domain — focusing on versioning with the clean, standardized style of Spring Boot 4.
🛑 The Problem: Why “DTOs” Are Not Enough
A common question I hear is:
“Why do I need versioning? Can’t I just update my DTOs?”
🏨 Imagine you run a hotel:
- The scenario: You have legacy Self-Check-In Kiosks in the lobby. They expect a room ID as an Integer.
- The innovation: You launch a new Mobile App. It uses secure UUIDs for room IDs and offers “Virtual Keys.”
- The Crash: If you update your DTOs to change id from Integer to UUID, every kiosk in your lobby crashes instantly.
It lets the old Kiosks (V1) and the new Mobile App (V2) communicate with the same backend simultaneously without conflict.
🕰️ Before v/s After: The Evolution
🏁 Key Highlights
- New version attribute on all mapping annotations (e.g.: On @RequestMapping, @GetMapping, etc.)
- Same path, different versions in one controller — clean and co-located
- Supports semantic versioning (major.minor.patch) with ranges and baselines:
version = "1.0" → exact
version = "2+" → 2.0 and all compatible newer versions until overridden
Automatic selection of the highest compatible version
- Customising the version parsing with ApiVersionParser interface
- Built-in deprecation handling: Auto-add Deprecation, Sunset, Link headers for old versions through @Configuration
- Works seamlessly with clients (RestClient, WebClient) and tests (MockMvc, WebTestClient, etc)
- 400: Bad Request if no version is provided (unless configured otherwise) with setVersionRequired(boolean)
- And, many more we’re going to learn below…
ℹ️ API Versioning: Four strategies (The Introduction)
Spring Boot 4 supports all four major versioning strategies out of the box. In our Hospitality App example, we will configure all of them to demonstrate flexibility.
Please Note: Path Segment Versioning cannot combine with other strategies. Once enabled, it takes precedence and overrides other versioning methods. For clarity and consistency, select one or two strategies (max) and apply them consistently across your application.
1️⃣ Path Segment Versioning (The Standard)
- Format: /api/v1/rooms
- Best For: Public APIs, OTAs (Expedia/Booking.com, Distribution, etc). Easy to cache via CDNs.
2️⃣ Header Versioning (The Cleanest)
- Format: Header X-API-Version: 2.0 with URL /api/rooms
- Best For: Internal Micro-services. Keeps URLs clean and “resource-focused.”
3️⃣ Query Parameter Versioning (The Quickest)
- Format: /api/rooms?version=1.0
- Best For: Ad-hoc scripts, quick debugging, or legacy browser clients.
4️⃣ Media Type Versioning aka Content Negotiation (The Purist)
- Format: Set Header (any one to individual APIs) as:
Accept: application/vnd.hotel.v2+json
Accept: application/vnd.api+json; version=2.0
Accept: application/json; version=2.0
- Best For: Strict REST paradigms where the representation changes, not just the data.
ℹ️ API Versioning: Four Strategies (The Implementation)
The “Hotel Room” API: Let’s build a simple example transitioning a Room API from V1 (Legacy) to V2 (Modern) to illustrate all strategy implementations.
☑️ STEP I — The Brain: Two Main Approaches for Configuring API Versioning
1️⃣ Implement “WebMvcConfigurer” Interface (Recommended)
config/AppVersionConfig.java
package dev.santhoshjohn.sb4versioning.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.accept.ApiVersionDeprecationHandler;
import org.springframework.web.accept.StandardApiVersionDeprecationHandler;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.net.URI;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
@Configuration
public class AppVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
// 1. Default Behavior (Safety Net)
// .setDefaultVersion("2.0")
.setVersionRequired(true) // Reject requests without version info
// 2. STRATEGIES
.usePathSegment(1) // Path: /api/v1/...
// .useRequestHeader("X-API-Version") // Header: X-API-Version: 1
// .useQueryParam("version") // Query: ?version=1
// .useMediaTypeParameter(MediaType.APPLICATION_JSON, "version") // Accept: ...; version=1
// OR
// .useMediaTypeParameter(MediaType.parseMediaType("application/vnd.api+json"), "version")
// 3. DEPRECATION HANDLER (Registered Once)
.setDeprecationHandler(deprecationHandler());
}
/**
* Unified reusable version deprecation handler
* Supports one version or many versions.
*/
public ApiVersionDeprecationHandler deprecationHandler() {
StandardApiVersionDeprecationHandler handler = new StandardApiVersionDeprecationHandler();
// Configure deprecated versions here:
configureDeprecatedVersions(
handler,
List.of("v1", "v1.2", "v1.3.5"), // <-- single or multiple versions
ZonedDateTime.of(LocalDate.of(2025, 1, 1), LocalTime.MIDNIGHT, ZoneId.systemDefault()),
ZonedDateTime.of(LocalDate.of(2026, 12, 31), LocalTime.MIDNIGHT, ZoneId.systemDefault()),
URI.create("https://api.sj.dev/migration-guide")
);
return handler;
}
/**
* Helper method: Apply deprecation settings to one or many versions.
*/
private void configureDeprecatedVersions(
StandardApiVersionDeprecationHandler handler,
List<String> versions,
ZonedDateTime deprecationDate,
ZonedDateTime sunsetDate,
URI sunsetLink
) {
for (String version : versions) {
handler.configureVersion(version)
.setDeprecationDate(deprecationDate)
.setSunsetDate(sunsetDate)
.setSunsetLink(sunsetLink);
}
}
}
2️⃣ Properties-Based Configuration (properties or yml)
resources/application.properties
# Basic Versioning Configuration
spring.mvc.apiversion.supported=1.0,2.0
spring.mvc.apiversion.default=1.0
# Add only ONE of these strategies: -
# Path Segment Versioning (e.g., /api/v1/rooms)
spring.mvc.apiversion.use.path-segment=1
# Request Header Versioning (e.g., X-API-Version: 1.0)
spring.mvc.apiversion.use.header=X-API-Version
# Query Parameter Versioning (e.g., ?version=1.0)
spring.mvc.apiversion.use.query-parameter=version
# Media Type Parameter Versioning (e.g., Accept: application/json;version=1.0)
spring.mvc.apiversion.use.media-type-parameter[application/json]=version
resources/application.yml
spring:
mvc:
apiversion:
# Basic Versioning Configuration
supported: 1.0, 2.0
default: 1.0
# Add only ONE of these strategies: -
# Path Segment Versioning (e.g., /api/v1/rooms)
use:
path-segment: 1
# Request Header Versioning (e.g., X-API-Version: 1.0)
header: "X-API-Version"
# Query Parameter Versioning (e.g., ?version=1.0)
query-parameter: "version"
# Media Type Parameter Versioning (e.g., Accept: application/json;version=1.0)
media-type-parameter:
application/json: "version"
☑️ STEP II — Our Actual Entities/Models
Codebase Structure
1️⃣ Java Records: Our Data Contracts
dto/RoomDtoV1.java
package dev.santhoshjohn.sb4versioning.dto;
// === VERSION 1: For Legacy Kiosks ===
public record RoomDtoV1(int id, String data, String note) { }
dto/RoomDtoV2.java
package dev.santhoshjohn.sb4versioning.dto;
import java.util.UUID;
// === VERSION 2: For Mobile Apps ===
public record RoomDtoV2(UUID uuid, String enrichedData, MetaData meta) { }
dto/MetaData.java
package dev.santhoshjohn.sb4versioning.dto;
// === VERSION 2: Metadata For Mobile Apps ===
public record MetaData(String version, String timestamp) { }
2️⃣ Room Controller: For Path Segment Versioning Strategy (Rooms API)
Room controller specifically uses {version} in the URL like- …/api/v2/rooms/…
package dev.santhoshjohn.sb4versioning.controller;
import dev.santhoshjohn.sb4versioning.dto.MetaData;
import dev.santhoshjohn.sb4versioning.dto.RoomDtoV1;
import dev.santhoshjohn.sb4versioning.dto.RoomDtoV2;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.UUID;
// ---------------------------------------------------------
// STRATEGY 1: Path Versioning
// URL Pattern: /api/{version}/rooms
// ---------------------------------------------------------
@RestController
@RequestMapping("/api/{version}/rooms")
public class RoomController {
// V1: /api/v1/rooms/101
@GetMapping(value = "/{id}", version = "1")
public ResponseEntity<RoomDtoV1> getRoomV1(@PathVariable int id) {
return ResponseEntity.ok(new RoomDtoV1(id, "Legacy Room", "Using Path V1"));
}
// V2: /api/v2/rooms/c4c3d8cd-0c41-4c54-bdb5-4c5a51d689df
@GetMapping(value = "/{id}", version = "2+")
public ResponseEntity<RoomDtoV2> getRoomV2(@PathVariable UUID id) {
return ResponseEntity.ok(new RoomDtoV2(id, "Luxury Suite", new MetaData("V2", Instant.now().toString())));
}
}
3️⃣ Guest Controller: For Header/Query/Media Strategy (Guests API)
This controller has clean URL (…/api/guests). It relies on the other 3 strategies configured in AppVersionConfig.
package dev.santhoshjohn.sb4versioning.controller;
import dev.santhoshjohn.sb4versioning.dto.MetaData;
import dev.santhoshjohn.sb4versioning.dto.RoomDtoV1;
import dev.santhoshjohn.sb4versioning.dto.RoomDtoV2;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.UUID;
// ---------------------------------------------------------
// STRATEGIES 2, 3, 4: Header / Query / Media Type
// URL Pattern: /api/guests (No version in path!)
// ---------------------------------------------------------
@RestController
@RequestMapping("/api/guests")
public class GuestController {
// Triggered by:
// Header: X-API-Version: 1
// Query: ?version=1
// Accept: application/json; version=1
@GetMapping(value = "/{id}", version = "1.0")
public ResponseEntity<RoomDtoV1> getGuestV1(@PathVariable int id) {
return ResponseEntity.ok(new RoomDtoV1(id, "Santhosh John - v1", "Using Header/Query V1"));
}
// Triggered by:
// Header: X-API-Version: 2
// Query: ?version=2
// Accept: application/json; version=2
@GetMapping(value = "/{id}", version = "2.0+")
public ResponseEntity<RoomDtoV2> getGuestV2(@PathVariable UUID id) {
return ResponseEntity.ok(new RoomDtoV2(
id,
"Santhosh John - v2",
new MetaData("V2 - CleanURL", Instant.now().toString())
));
}
}
4️⃣ Main: Spring Boot App
package dev.santhoshjohn.sb4versioning;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Sb4VersioningApplication {
public static void main(String[] args) {
SpringApplication.run(Sb4VersioningApplication.class, args);
}
}
🏃♂️ Run and Test All Strategies (One-by-One) 🏃♀️
After running the application (main app), open your terminal to test it with cURL or any agent (e.g., POSTMAN, Hoppscotch, Requestly, Insomnia, HttPie, Bruno, etc). We will use POSTMAN for our checks.
1️⃣ Path Segment Versioning (Strategy I)
Adjust your config code to use: usePathSegment(1). Here, 1 indicates the index of the path segment to check. For example, for URLs like “/{version}/…”, use index 0; for “/api/{version}/…”, use index 1.
🕹️ RoomController (v1)
http://localhost:8080/api/v1/rooms/101
(Deprecation, Sunset, and Link are set in Response Headers)
🕹️ RoomController (v2)
http://localhost:8080/api/v2/rooms/c4c3d8cd-0c41-4c54-bdb5-4c5a51d689df
(No Deprecation related Response Headers are set)
Please Note: Since error scenarios are similar across strategies, I will not post errors for other strategies to keep it brief. You can test them yourself for clarity.
2️⃣ Request Header Versioning (Strategy II)
Adjust your config code to use: useRequestHeader(“X-API-Version”). The header name X-API-Version is custom name. It need not have to be -API-Version, but that is widely used.
🕹️ GuestController (v1)
http://localhost:8080/api/guests/101
(400: Bad Request by default if no Request Header is set)
🕹️ GuestController (v1)
http://localhost:8080/api/guests/101
Accept: X-Api-Version=1
(Once you set the Request Header with the correct version: Deprecation, Sunset, and Link are set in the Response Headers)
🕹️ GuestController (v2)
http://localhost:8080/api/guests/c4c3d8cd-0c41-4c54-bdb5-4c5a51d689df
Accept: X-Api-Version=2
(No Deprecation related Response Headers are set)
3️⃣ Query Parameter Versioning (Strategy III)
Adjust your config code to use: useQueryParam(“version”). Here version is the parameter name to check. It need not have to be version but that is widely used.
🕹️ GuestController (v1)
http://localhost:8080/api/guests/103?version=1
(Deprecation, Sunset, and Link are set in Response Headers)
🕹️ GuestController (v2)
http://localhost:8080/api/guests/c4c3d8cd-0c41-4c54-bdb5-4c5a51d689df?version=2
(No Deprecation related Response Headers are set)
4️⃣ Media Type / Content Negotiation Versioning (Strategy IV)
❶ Adjust your config code to use: useMediaTypeParameter(MediaType.APPLICATION_JSON, “version”). Here version is the parameter name to check. It need not have to be version but that is widely used.
🕹️ GuestController (v1)
http://localhost:8080/api/guests/101
Accept: application/json;version=1
(Deprecation, Sunset, and Link are set in Response Headers)
❷ Adjust your config code to use: useMediaTypeParameter(MediaType.parseMediaType(“application/vnd.api+json”), “version”). Here version is the parameter name to check. It need not have to be version but that is widely used.
🕹️ GuestController (v2)
http://localhost:8080/api/guests/c4c3d8cd-0c41-4c54-bdb5-4c5a51d689df
Accept: application/vnd.api+json;version=2
(No Deprecation related Response Headers are set)
🙌 Bonus: Functional Endpoints
Spring 7/Spring Boot 4 fully supports the new version attribute on functional endpoints, not just on @Controller/@RestController. You can apply API versioning with the Functional Endpoints (RouterFunction + HandlerFunction) approach. The example below uses Path Segment Versioning and Request Header Versioning. Adjust and combine as needed — in the configuration file.
package dev.santhoshjohn.sb4versioning.config;
import dev.santhoshjohn.sb4versioning.dto.MetaData;
import dev.santhoshjohn.sb4versioning.dto.RoomDtoV1;
import dev.santhoshjohn.sb4versioning.dto.RoomDtoV2;
import org.jspecify.annotations.NonNull;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import java.time.Instant;
import java.util.UUID;
import static org.springframework.web.servlet.function.RequestPredicates.version;
@Configuration
public class ApiFunctionalEndpoints {
private static final String ROOMS_PATH = "/api/{version}/rooms/{id}";
private static final String GUESTS_PATH = "/api/guests/{id}";
@Bean
public RouterFunction<@NonNull ServerResponse> apiRoutes() {
return RouterFunctions.route()
.GET(ROOMS_PATH, version("1"), this::getRoomV1)
.GET(ROOMS_PATH, version("2+"), this::getRoomV2)
.GET(GUESTS_PATH, version("1.0"), this::getGuestV1)
.GET(GUESTS_PATH, version("2.0+"), this::getGuestV2)
.build();
}
// -------------------------- Room V1 (int id) --------------------------
private ServerResponse getRoomV1(ServerRequest request) {
int id = Integer.parseInt(request.pathVariable("id"));
var dto = new RoomDtoV1(id, "Legacy Room", "Using Path Segment Versioning - V1");
return ServerResponse.ok()
// .header("X-API-Version", "1")
// .header("Vary", "X-API-Version") // Critical for caching!
.body(dto);
}
// -------------------------- Room V2+ (UUID id) ------------------------
private ServerResponse getRoomV2(ServerRequest request) {
UUID id = UUID.fromString(request.pathVariable("id"));
var dto = new RoomDtoV2(
id,
"Luxury Suite",
new MetaData("V2", Instant.now().toString())
);
return ServerResponse.ok()
// .header("X-API-Version", "2")
// .header("Vary", "X-API-Version")
.body(dto);
}
// -------------------------- Guest V1 (int id) -------------------------
private ServerResponse getGuestV1(ServerRequest request) {
int id = Integer.parseInt(request.pathVariable("id"));
var dto = new RoomDtoV1(id, "Santhosh John - v1", "Header/Query/MediaType → v1");
return ServerResponse.ok()
.header("X-API-Version", "1.0")
.header("Vary", "X-API-Version")
.body(dto);
}
// -------------------------- Guest V2+ (UUID id) -----------------------
private ServerResponse getGuestV2(ServerRequest request) {
UUID id = UUID.fromString(request.pathVariable("id"));
var dto = new RoomDtoV2(
id,
"Santhosh John - v2",
new MetaData("V2-CleanURL", Instant.now().toString())
);
return ServerResponse.ok()
.header("X-API-Version", "2.0")
.header("Vary", "X-API-Version")
.body(dto);
}
}
Thank you for your time. Keep Learning. Keep Growing.🙂
















Top comments (0)