Here is the translated article body:


You want to introduce breaking changes to your API without breaking existing clients — this situation comes up in virtually every project that continues to evolve.

There are three primary approaches to REST API versioning: URI path, custom request headers, and content negotiation via the Accept header. None of these is the “right answer” — each involves trade-offs. This article walks through the characteristics and selection criteria for each approach, with Spring Boot implementation examples.

For basic CRUD implementation, see the Spring Boot REST API CRUD Tutorial. For exception handling, check out Spring Boot REST API Exception Handling.

Approach 1: URI Path Versioning

This is the most commonly seen approach. The version number is embedded in the path, such as /api/v1/users or /api/v2/users.

@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {

    @GetMapping("/{id}")
    public UserV1Response getUser(@PathVariable Long id) {
        // v1 response format
        return new UserV1Response(id, "山田太郎");
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {

    @GetMapping("/{id}")
    public UserV2Response getUser(@PathVariable Long id) {
        // v2 splits fullName into firstName/lastName
        return new UserV2Response(id, "山田", "太郎");
    }
}

The advantage is simplicity. You can type the URL directly into a browser’s address bar, and testing with curl is straightforward. Proxy and CDN caching works well, and the version is immediately visible in logs.

The disadvantage is that the version bleeds into the URI. REST principles ideally call for “the same resource at the same URI,” so having both /users/1 and /api/v2/users/1 refer to the same resource is technically awkward. That said, this is widely accepted in practice.

Approach 2: Custom Request Header Versioning

This approach specifies the version via a custom header such as X-API-Version: 2. Routing is handled through the headers attribute of @RequestMapping.

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

    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public UserV1Response getUserV1(@PathVariable Long id) {
        return new UserV1Response(id, "山田太郎");
    }

    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public UserV2Response getUserV2(@PathVariable Long id) {
        return new UserV2Response(id, "山田", "太郎");
    }
}

The advantage is that the URI stays as /api/users/{id} without change. This is convenient in environments where clients have full control over headers, such as service-to-service communication or internal APIs.

On the other hand, sending requests directly from a browser is difficult — you will need curl or Postman for testing. When using custom headers, make sure to add them to Access-Control-Allow-Headers in the CORS preflight (OPTIONS) request. See the CORS Configuration Guide for reference.

Approach 3: Accept Header / Content Negotiation

This is the approach most faithful to the HTTP specification. The version is expressed via a vendor media type such as Accept: application/vnd.myapp.v2+json.

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

    @GetMapping(
        value = "/{id}",
        produces = "application/vnd.myapp.v1+json"
    )
    public UserV1Response getUserV1(@PathVariable Long id) {
        return new UserV1Response(id, "山田太郎");
    }

    @GetMapping(
        value = "/{id}",
        produces = "application/vnd.myapp.v2+json"
    )
    public UserV2Response getUserV2(@PathVariable Long id) {
        return new UserV2Response(id, "山田", "太郎");
    }
}

The advantage is alignment with HTTP’s design philosophy, allowing you to declare both the media type and the version in a single header. However, clients must specify the Accept header precisely, which raises implementation cost. Displaying this approach correctly in Swagger UI also requires extra effort.

Comparison of the Three Approaches

CriteriaURI PathCustom HeaderAccept Header
Readability
Caching
Client cost
REST compliance
Browser testing
Swagger UI support

Which Approach Should You Choose?

Here is a framework for deciding when in doubt:

  • Public APIs or APIs accessed from browsers → URI path is the safe choice. Readability and testability are the top priorities.
  • Internal APIs or machine-to-machine communication → Custom headers become a viable option. You can keep URIs clean while controlling the version through headers.
  • Strict adherence to HTTP specification → The Accept header approach is ideal, but carefully weigh the implementation burden on the client side.

Honestly, for most projects, URI path versioning is the practical first choice. The simplicity of operation tends to outweigh the drawbacks.

Displaying Multiple Versions in Swagger UI with SpringDoc OpenAPI

If you have already completed the basic setup from the SpringDoc OpenAPI Setup Guide, you can use GroupedOpenApi to display v1 and v2 as separate groups.

@Configuration
public class OpenApiConfig {

    @Bean
    public GroupedOpenApi v1Api() {
        return GroupedOpenApi.builder()
            .group("v1")
            .pathsToMatch("/api/v1/**")
            .build();
    }

    @Bean
    public GroupedOpenApi v2Api() {
        return GroupedOpenApi.builder()
            .group("v2")
            .pathsToMatch("/api/v2/**")
            .build();
    }
}

This lets you switch between v1 and v2 using the dropdown in Swagger UI. For the custom header and Accept header approaches, you can also use addOperationCustomizer to append version information to the documentation, but URI path versioning integrates most cleanly.

Deprecation Notices for Retired Versions

When planning to retire v1, it is good practice to notify clients via response headers. The Sunset header defined in RFC 8594 is well-suited for this.

@Component
public class DeprecationFilter extends OncePerRequestFilter {

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

        chain.doFilter(request, response);

        if (request.getRequestURI().startsWith("/api/v1/")) {
            response.setHeader("Deprecation", "true");
            response.setHeader("Sunset", "Sat, 31 Dec 2026 23:59:59 GMT");
            response.setHeader("Link", "</api/v2/>; rel=\"successor-version\"");
        }
    }
}

This automatically attaches the planned deprecation date to all v1 requests. To give client teams enough time to migrate, aim to put this in place six months to a year before the actual retirement.

Implementation Pitfalls

When AmbiguousMapping exceptions occur

If multiple controllers are mapped to the same path, an exception will be thrown at startup. Even with URI path versioning, ensure that @RequestMapping path definitions do not overlap.

CORS and custom headers

When using the custom header approach, failing to add X-API-Version to allowedHeaders in your CORS configuration will cause preflight requests to fail.

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedHeaders(List.of("Content-Type", "X-API-Version"));
    // ...
}

Summary

There is no single “correct” answer when it comes to versioning strategies. URI path versioning is simple and broadly applicable; custom headers are a good fit for internal APIs where you want to keep URIs clean; and Accept headers are an option when strict specification compliance is a priority.

Start by confirming your team’s client types and operational policy before settling on an approach — that makes it far less likely you will want to change course later. When in doubt, start with URI path versioning and add Deprecation notices as needed. That is the most pragmatic approach.