Spring Boot: API Versioning Strategies: URI, Header & Content Negotiation

API versioning lets you evolve your REST API without breaking existing clients. There is no single correct approach: URI path versioning is the most visible and cache-friendly; header-based versioning keeps URLs clean; content negotiation via the Accept header is most RESTful. Each has real trade-offs that affect client UX, caching, and documentation.

Contents

Strategy 1: URI Path Versioning

Include the version in the URL path: /api/v1/users, /api/v2/users. The most common approach: easy to understand, works with every HTTP client, and is cache-friendly.

Java
// V1 controller
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {

    @GetMapping("/{id}")
    public UserResponseV1 getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return new UserResponseV1(user.getId(), user.getFullName(), user.getEmail());
    }
}

// V2 controller — adds phone and splits name into first/last
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {

    @GetMapping("/{id}")
    public UserResponseV2 getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return new UserResponseV2(user.getId(), user.getFirstName(),
                                  user.getLastName(), user.getEmail(), user.getPhone());
    }
}
Java
// V1 response — backwards compatible
public record UserResponseV1(Long id, String fullName, String email) {}

// V2 response — breaking change: splits fullName into first + last, adds phone
public record UserResponseV2(Long id, String firstName, String lastName,
                             String email, String phone) {}
Organise versioned controllers in packages (controller.v1, controller.v2) to keep the codebase navigable as the number of versions grows. Share service and repository layers across all versions.

Strategy 2: Custom Request Header

Route by a custom header such as X-API-Version: 2. Keeps URLs clean and stable; URLs can be bookmarked and shared without the version leaking into them.

Java
@RestController
@RequestMapping("/api/users")
public class UserController {

    // Default (no header) and v1
    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public UserResponseV1 getUserV1(@PathVariable Long id) {
        User user = userService.findById(id);
        return new UserResponseV1(user.getId(), user.getFullName(), user.getEmail());
    }

    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public UserResponseV2 getUserV2(@PathVariable Long id) {
        User user = userService.findById(id);
        return new UserResponseV2(user.getId(), user.getFirstName(),
                                  user.getLastName(), user.getEmail(), user.getPhone());
    }
}
Shell
# Client sends:
curl -H "X-API-Version: 2" https://api.example.com/api/users/42

Strategy 3: Accept Header (Content Negotiation)

Encode the version in the Accept media type: Accept: application/vnd.example.v2+json. The most RESTful approach (leverages HTTP as designed), but harder for clients and breaks browser-based testing.

Java
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(value = "/{id}",
                produces = "application/vnd.example.v1+json")
    public UserResponseV1 getUserV1(@PathVariable Long id) {
        User user = userService.findById(id);
        return new UserResponseV1(user.getId(), user.getFullName(), user.getEmail());
    }

    @GetMapping(value = "/{id}",
                produces = "application/vnd.example.v2+json")
    public UserResponseV2 getUserV2(@PathVariable Long id) {
        User user = userService.findById(id);
        return new UserResponseV2(user.getId(), user.getFirstName(),
                                  user.getLastName(), user.getEmail(), user.getPhone());
    }
}
Shell
curl -H "Accept: application/vnd.example.v2+json" https://api.example.com/api/users/42

Custom @ApiVersion Annotation

For larger APIs with many versioned endpoints, a custom annotation + RequestMappingHandlerMapping reduces boilerplate. Every controller annotated with @ApiVersion("v2") automatically gets the URI prefix.

Java
import java.lang.annotation.*;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
    String value();  // e.g. "v1", "v2"
}
Java
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.core.annotation.AnnotationUtils;

public class VersionedRequestMappingHandlerMapping extends RequestMappingHandlerMapping {

    @Override
    protected void detectHandlerMethods(Object handler) {
        super.detectHandlerMethods(handler);
    }

    // Prepend /api/{version} to every mapped path for versioned controllers
    @Override
    protected org.springframework.web.servlet.mvc.condition.RequestCondition<?>
              getCustomTypeCondition(Class<?> handlerType) {
        ApiVersion annotation = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class);
        return annotation != null ? new ApiVersionCondition(annotation.value()) : null;
    }
}
Java
// Usage — clean controller with no version in @RequestMapping
@RestController
@ApiVersion("v2")
@RequestMapping("/users")   // resolved to /api/v2/users
public class UserControllerV2 {
    @GetMapping("/{id}")
    public UserResponseV2 getUser(@PathVariable Long id) { ... }
}

Deprecating Old Versions

Add the Deprecation and Sunset response headers (RFC 8594) to old versions so clients are warned programmatically.

Java
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public ResponseEntity<UserResponseV1> getUserV1(@PathVariable Long id) {
    User user = userService.findById(id);
    return ResponseEntity.ok()
        .header("Deprecation", "true")
        .header("Sunset", "Sat, 01 Jan 2026 00:00:00 GMT")
        .header("Link", "<https://api.example.com/api/v2/users/" + id + ">; rel=\"successor-version\"")
        .body(new UserResponseV1(user.getId(), user.getFullName(), user.getEmail()));
}
Java
// Or apply globally via a HandlerInterceptor for all v1 routes
@Component
public class DeprecationInterceptor implements HandlerInterceptor {

    @Override
    public void postHandle(HttpServletRequest req, HttpServletResponse res,
                           Object handler, ModelAndView mv) {
        if (req.getRequestURI().contains("/v1/")) {
            res.setHeader("Deprecation", "true");
            res.setHeader("Sunset", "Sat, 01 Jan 2026 00:00:00 GMT");
        }
    }
}

Strategy Comparison

AspectURI PathCustom HeaderAccept Header
Discoverability✅ Excellent⚠️ Hidden⚠️ Hidden
Browser / curl friendliness✅ Easy⚠️ Needs header❌ Awkward
HTTP cache friendly✅ Yes⚠️ Needs Vary header⚠️ Needs Vary header
Clean URLs❌ Version in URL✅ Clean✅ Clean
RESTfulness⚠️ Resource changes URL⚠️ Non-standard✅ Uses HTTP content negotiation
OpenAPI / Swagger support✅ Easy⚠️ Extra config⚠️ Extra config
Industry adoption✅ Most commonModerateLess common

Recommendation: Use URI path versioning (/api/v1/, /api/v2/) for public APIs. It is the most understood approach across API consumers, works out of the box with CDNs and OpenAPI tooling, and requires zero client sophistication.