Spring BootAPI Versioning
Spring Boot

API Versioning

API versioning lets you evolve a REST API without breaking existing clients. Spring Boot supports four main strategies: URI path versioning, request parameter versioning, HTTP header versioning, and media type (Accept header) versioning. This entry covers all four strategies, 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. It is the most widely adopted convention and the safest default choice for public APIs.
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. It is less cache-friendly than path versioning and is less commonly seen in modern public APIs, but works well for internal or partner APIs.
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.
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(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(userServiceV2.findById(id));
    }

    // ── Default — no version header ───────────────────────────────────
    @GetMapping("/{id}")
    public ResponseEntity<UserResponseV2> findById(
            @PathVariable Long id) {
        return ResponseEntity.ok(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.
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

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

Version Deprecation

Deprecate old versions by adding a Deprecation response header and a Sunset header announcing the removal date, as defined in RFC 8594. A response filter applies these headers globally to all responses from deprecated version controllers without touching controller code.
Java
// ── Deprecation response filter ──────────────────────────────────────
@Component
public class ApiDeprecationFilter extends OncePerRequestFilter {

    // Versions mapped to their sunset date
    private static final Map<String, String> DEPRECATED = Map.of(
        "/api/v1/", "Sat, 01 Jan 2026 00:00:00 GMT"
    );

    @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", "true");
                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: true
// Sunset: Sat, 01 Jan 2026 00:00:00 GMT
// Link: </api/v2/users/1>; rel="successor-version"

// ── Version comparison guide ───────────────────────────────────────────
// Strategy        URI clean?  Cacheable?  Browser-friendly?  RESTful?
// Path (/v1/)     No          Yes         Yes                Moderate
// Parameter       Yes         Partial     Yes                Moderate
// Header          Yes         No          No                 Moderate
// Media type      Yes         No          No                 High