DEV Community

Cover image for 🏨 Spring Boot 4 & Spring Framework 7: Native API Versioning (No More Hacks)
Santhosh
Santhosh

Posted on

🏨 Spring Boot 4 & Spring Framework 7: Native API Versioning (No More Hacks)

🏨 Spring Boot 4 — Native API Versioning Guide

(With Path, Header, Query & Media-Type Versioning)


📚 Table of Contents

  1. ℹ️ Description and Rationale
  2. 🛑 The Problem: Why “DTOs” Are Not Enough
  3. 🕰️ Before v/s After: The Evolution
  4. 🏁 Key Highlights
  5. ℹ️ API Versioning: Four Strategies (Introduction)
  6. ☑️ Step I — Configuring API Versioning
    • Implementing WebMvcConfigurer
    • Properties/YAML Configuration
  7. ☑️ Step II — Entities & Models
  8. 🏃‍♂️ Testing All Strategies
  9. 🙌 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

API Versioning: Spring Boot 3 v/s Spring Boot 4

🏁 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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

☑️ STEP II — Our Actual Entities/Models

🫆 Codebase Structure

IntelliJ Idea: SB4 Versioning — Project 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) { }
Enter fullscreen mode Exit fullscreen mode

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) { }
Enter fullscreen mode Exit fullscreen mode

dto/MetaData.java

package dev.santhoshjohn.sb4versioning.dto;

// === VERSION 2: Metadata For Mobile Apps ===
public record MetaData(String version, String timestamp) { }
Enter fullscreen mode Exit fullscreen mode

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())));
    }
}
Enter fullscreen mode Exit fullscreen mode

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())
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

🏃‍♂️ 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)

Postman Output: SB4 Versioning — Path Segment Versioning (v1)

Postman Output: SB4 Versioning — Path Segment Versioning (v1 with Deprecation, Sunset and Link)

🕹️ RoomController (v2)

http://localhost:8080/api/v2/rooms/c4c3d8cd-0c41-4c54-bdb5-4c5a51d689df
(No Deprecation related Response Headers are set)

Postman Output: SB4 Versioning — Path Segment Versioning (v2)

Postman Output: SB4 Versioning — Path Segment Versioning (v2 with no Response Headers)

Postman Output: SB4 Versioning — Path Segment Versioning (v2 with wrong input — no Headers)

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)

Postman Output: SB4 Versioning — Request Header Versioning (with no Request Header 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)

Postman Output: SB4 Versioning — Request Header Versioning (v1 with Request Header set)

Postman Output: SB4 Versioning — Request Header Versioning (v1 with Deprecation, Sunset and Link)

🕹️ GuestController (v2)

http://localhost:8080/api/guests/c4c3d8cd-0c41-4c54-bdb5-4c5a51d689df
Accept: X-Api-Version=2
(No Deprecation related Response Headers are set)

Postman Output: SB4 Versioning — Request Header Versioning (v2 with Request Header 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)

Postman Output: SB4 Versioning — Query Parameter Versioning (v1)

Postman Output: SB4 Versioning — Query Parameter Versioning (v1 with Deprecation, Sunset and Link)

🕹️ GuestController (v2)

http://localhost:8080/api/guests/c4c3d8cd-0c41-4c54-bdb5-4c5a51d689df?version=2
(No Deprecation related Response Headers are set)

Postman Output: SB4 Versioning — Query Parameter Versioning (v2 with no Response Headers)


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)

Postman Output: SB4 Versioning — Media Type Versioning (v1 with Deprecation, Sunset and Link)

❷ 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)

Postman Output: SB4 Versioning — Media Type Versioning (v2 with no Response Headers)


🙌 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Thank you for your time. Keep Learning. Keep Growing.🙂

Top comments (0)