Spring Boot

API Versioning

API versioning lets you evolve a REST API without breaking existing clients. Spring Boot 4 / Spring Framework 7 adds built-in API versioning, and the four classic strategies (URI path, request parameter, HTTP header, media type) still work in any version. This entry covers both approaches, how to choose between them, version deprecation, and routing multiple versions from a single codebase.

URI Path Versioning

URI path versioning embeds the version in the URL: /api/v1/users, /api/v2/users. It is the most visible and cache-friendly strategy — proxies and CDNs treat each version as a distinct resource, and you can try it directly in a browser. It is the most widely adopted convention and the safest default choice for public APIs. (The examples in the first four sections use separate UserServiceV1 and UserServiceV2 beans only to keep each strategy self-contained; the recommended structure, with one shared service, is shown in "Shared Service Layer Across Versions".)
Java
// ── V1 Controller ─────────────────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserControllerV1 {

    private final UserServiceV1 userService;

    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV1> findById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @GetMapping
    public ResponseEntity<Page<UserResponseV1>> findAll(
            @PageableDefault(size = 20) Pageable pageable) {
        return ResponseEntity.ok(userService.findAll(pageable));
    }
}

// ── V2 Controller — richer response, new fields ───────────────────────
@RestController
@RequestMapping("/api/v2/users")
@RequiredArgsConstructor
public class UserControllerV2 {

    private final UserServiceV2 userService;

    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV2> findById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @GetMapping
    public ResponseEntity<Page<UserResponseV2>> findAll(
            @PageableDefault(size = 20) Pageable pageable) {
        return ResponseEntity.ok(userService.findAll(pageable));
    }
}

// ── V1 DTO ────────────────────────────────────────────────────────────
public record UserResponseV1(Long id, String name, String email) {}

// ── V2 DTO — adds avatar, role, and nested address ────────────────────
public record UserResponseV2(
    Long    id,
    String  name,
    String  email,
    String  avatarUrl,
    String  role,
    Address address
) {}

Request Parameter Versioning

Parameter versioning appends a version query parameter to the URL: /users?version=1. It keeps the base URI stable and is easy to test in a browser. Caches must include the query string in their key, so it is only partly cache-friendly, and it is less common in modern public APIs, but it works well for internal or partner APIs. Always define a default handler for requests without the parameter, otherwise those requests return 404 or 400.
Java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserServiceV1 userServiceV1;
    private final UserServiceV2 userServiceV2;

    // ── V1 handler: GET /api/users/{id}?version=1 ─────────────────────
    @GetMapping(value = "/{id}", params = "version=1")
    public ResponseEntity<UserResponseV1> findByIdV1(
            @PathVariable Long id) {
        return ResponseEntity.ok(userServiceV1.findById(id));
    }

    // ── V2 handler: GET /api/users/{id}?version=2 ─────────────────────
    @GetMapping(value = "/{id}", params = "version=2")
    public ResponseEntity<UserResponseV2> findByIdV2(
            @PathVariable Long id) {
        return ResponseEntity.ok(userServiceV2.findById(id));
    }

    // ── Default (no version param) — serve latest ─────────────────────
    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV2> findById(
            @PathVariable Long id) {
        return ResponseEntity.ok(userServiceV2.findById(id));
    }
}

// ── Example requests ──────────────────────────────────────────────────
// GET /api/users/1?version=1  → UserResponseV1
// GET /api/users/1?version=2  → UserResponseV2
// GET /api/users/1            → UserResponseV2 (default)

HTTP Header Versioning

Header versioning passes the version in a custom request header: X-API-Version: 2. The URI stays clean and stable across all versions. It requires clients to set a custom header and is harder to test in a browser, making it more suitable for API-to-API communication than for browser-facing APIs. Responses that differ by header should carry Vary: X-API-Version so shared caches and CDNs store each version separately; without it a cache may serve the wrong version.
Java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserServiceV1 userServiceV1;
    private final UserServiceV2 userServiceV2;

    // ── V1: X-API-Version: 1 ─────────────────────────────────────────
    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public ResponseEntity<UserResponseV1> findByIdV1(
            @PathVariable Long id) {
        return ResponseEntity.ok()
            .header(HttpHeaders.VARY, "X-API-Version")
            .body(userServiceV1.findById(id));
    }

    // ── V2: X-API-Version: 2 ─────────────────────────────────────────
    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public ResponseEntity<UserResponseV2> findByIdV2(
            @PathVariable Long id) {
        return ResponseEntity.ok()
            .header(HttpHeaders.VARY, "X-API-Version")
            .body(userServiceV2.findById(id));
    }

    // ── Default — no version header ───────────────────────────────────
    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV2> findById(
            @PathVariable Long id) {
        return ResponseEntity.ok()
            .header(HttpHeaders.VARY, "X-API-Version")
            .body(userServiceV2.findById(id));
    }
}

// ── Example requests ──────────────────────────────────────────────────
// GET /api/users/1  X-API-Version: 1  →  UserResponseV1
// GET /api/users/1  X-API-Version: 2  →  UserResponseV2
// GET /api/users/1                    →  UserResponseV2 (default)

Media Type (Accept Header) Versioning

Vendor media type versioning encodes the version inside the Accept header using a custom MIME type: Accept: application/vnd.myapp.user.v2+json. This is the most RESTful approach — URIs remain stable and the version is part of content negotiation. It requires careful documentation and is less intuitive for new consumers. Clients that send no Accept header (or Accept: */*) can match every handler, so define an explicit default and test this case; otherwise Spring may report an ambiguous mapping. As with header versioning, send Vary: Accept so caches keep versions apart.
Java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserServiceV1 userServiceV1;
    private final UserServiceV2 userServiceV2;

    // ── V1: Accept: application/vnd.myapp.user.v1+json ───────────────
    @GetMapping(
        value    = "/{id}",
        produces = "application/vnd.myapp.user.v1+json"
    )
    public ResponseEntity<UserResponseV1> findByIdV1(
            @PathVariable Long id) {
        return ResponseEntity.ok(userServiceV1.findById(id));
    }

    // ── V2: Accept: application/vnd.myapp.user.v2+json ───────────────
    @GetMapping(
        value    = "/{id}",
        produces = "application/vnd.myapp.user.v2+json"
    )
    public ResponseEntity<UserResponseV2> findByIdV2(
            @PathVariable Long id) {
        return ResponseEntity.ok(userServiceV2.findById(id));
    }

    // ── Default: Accept: application/json ────────────────────────────
    @GetMapping(
        value    = "/{id}",
        produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity<UserResponseV2> findById(
            @PathVariable Long id) {
        return ResponseEntity.ok(userServiceV2.findById(id));
    }
}

// ── Example requests ──────────────────────────────────────────────────
// GET /api/users/1  Accept: application/vnd.myapp.user.v1+json  → V1
// GET /api/users/1  Accept: application/vnd.myapp.user.v2+json  → V2
// GET /api/users/1  Accept: application/json                    → V2

Built-in API Versioning (Spring Framework 7 / Spring Boot 4)

Spring Framework 7 adds first-class API versioning. You choose one strategy (header, query parameter, path segment or media type parameter) in one place, then declare the version on each handler with the new version attribute on @GetMapping and the other mapping annotations. Spring resolves the version from the request, validates it against the supported versions, applies a default when none is sent, and can add deprecation headers for you. This removes the need for separate params, headers or produces conditions on every method. It requires Spring Framework 7 / Spring Boot 4; on older versions use the manual strategies above. If you use path versioning, the version must be a URI variable, for example /api/{version}/users.
Java
// ── Configure once ─────────────────────────────────────────────────────
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {

    @Override
    public void configureApiVersioning(ApiVersionConfigurer configurer) {
        configurer.useRequestHeader("X-API-Version")
                  .addSupportedVersions("1", "2")
                  .setDefaultVersion("2");
    }
}

// Or in application.properties (Spring Boot 4):
// spring.mvc.apiversion.use.header=X-API-Version

// ── Declare the version on each handler ───────────────────────────────
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping(path = "/{id}", version = "1")
    public UserResponseV1 findByIdV1(@PathVariable Long id) {
        return UserResponseV1.from(userService.findById(id));
    }

    @GetMapping(path = "/{id}", version = "2")
    public UserResponseV2 findByIdV2(@PathVariable Long id) {
        return UserResponseV2.from(userService.findById(id));
    }
}

// ── Example requests ──────────────────────────────────────────────────
// GET /api/users/1   X-API-Version: 1  → UserResponseV1
// GET /api/users/1   X-API-Version: 2  → UserResponseV2
// GET /api/users/1                     → UserResponseV2 (default)

Shared Service Layer Across Versions

Controllers are versioned; the service and domain layer are not. Each version's controller maps its DTO from the same underlying domain object. This avoids duplicating business logic and keeps versioning concerns isolated to the API layer. When a new version only changes the response shape, adding a V3 means adding one controller (or one handler) and one DTO, with no change to the service.
Java
// ── Single domain service — no versioning ────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepo;

    public User findById(Long id) {
        return userRepo.findById(id)
            .orElseThrow(() -> new EntityNotFoundException(
                "User not found: " + id));
    }

    public Page<User> findAll(Pageable pageable) {
        return userRepo.findAll(pageable);
    }
}

// ── V1 controller — maps domain object to V1 DTO ─────────────────────
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserControllerV1 {

    private final UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV1> findById(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(
            new UserResponseV1(user.getId(), user.getName(), user.getEmail()));
    }
}

// ── V2 controller — maps same domain object to V2 DTO ────────────────
@RestController
@RequestMapping("/api/v2/users")
@RequiredArgsConstructor
public class UserControllerV2 {

    private final UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV2> findById(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(new UserResponseV2(
            user.getId(),
            user.getName(),
            user.getEmail(),
            user.getAvatarUrl(),
            user.getRole().name(),
            Address.from(user.getAddress())
        ));
    }
}

Choosing a Versioning Strategy

Path versioning is the easiest to cache, document and try in a browser, so it is the safest default for public APIs. Header and media type versioning keep URIs clean, but they need a Vary header (Vary: X-API-Version or Vary: Accept) so caches store each version separately, and they are harder for new consumers to discover. Parameter versioning is simple to try but only partly cacheable and rarely used for public APIs. Media type versioning is the most RESTful because the version is part of content negotiation, at the cost of more documentation. If you are on Spring Boot 4, prefer the built-in support: pick one strategy in configuration and let Spring handle resolution, validation and defaults. Whichever you choose, use it consistently across the whole API, and avoid mixing strategies on the same resource.
Java
// ── Summary ───────────────────────────────────────────────────────────
// Strategy      Clean URI   Caching                 Browser testing   RESTful
// Path (/v1/)   No          Easy                    Easy              Moderate
// Parameter     Yes         Partial (query string)  Easy              Moderate
// Header        Yes         Needs Vary header       Hard              Moderate
// Media type    Yes         Needs Vary header       Hard              High
// Built-in (7)  Depends     Depends on strategy     Depends           Depends

// ── Cache-safe response for header / media type versioning ────────────
return ResponseEntity.ok()
    .header(HttpHeaders.VARY, "X-API-Version")   // or "Accept"
    .body(response);

Version Deprecation

Deprecate old versions by adding a Deprecation response header and a Sunset header announcing the removal date. The Sunset header is defined in RFC 8594 and the Deprecation header in RFC 9745. Sunset uses an HTTP-date, which must have the correct weekday, and Deprecation uses the Unix timestamp of the deprecation date prefixed with @. A response filter applies these headers to every response from a deprecated version without touching controller code. Spring Boot 4 can also add the headers for you when you configure a deprecation handler. Announce a deprecation well before the sunset date, log which clients still call the old version, and return a Link header pointing to the successor version.
Java
// ── Deprecation response filter ──────────────────────────────────────
@Component
public class ApiDeprecationFilter extends OncePerRequestFilter {

    // Deprecated prefix → sunset date (HTTP-date, weekday must be correct)
    private static final Map<String, String> DEPRECATED = Map.of(
        "/api/v1/", "Fri, 01 Jan 2027 00:00:00 GMT"
    );

    // Deprecation date as Unix timestamp: 2026-01-01 00:00:00 UTC
    private static final String DEPRECATED_AT = "@1767225600";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        String uri = request.getRequestURI();
        DEPRECATED.entrySet().stream()
            .filter(e -> uri.startsWith(e.getKey()))
            .findFirst()
            .ifPresent(e -> {
                response.setHeader("Deprecation", DEPRECATED_AT);
                response.setHeader("Sunset", e.getValue());
                response.setHeader("Link",
                    "</api/v2" + uri.substring(e.getKey().length() - 1)
                    + ">; rel=\"successor-version\"");
            });

        chain.doFilter(request, response);
    }
}

// ── Response headers on a deprecated endpoint ─────────────────────────
// HTTP/1.1 200 OK
// Deprecation: @1767225600
// Sunset: Fri, 01 Jan 2027 00:00:00 GMT
// Link: </api/v2/users/1>; rel="successor-version"