Versioning a Spring Boot REST API with content negotiation

Content negotiation lets a client choose a representation of the same resource through the HTTP Accept header. For a Spring Boot REST API, this can carry the contract version, keeping resource URLs stable while allowing older and newer response shapes to coexist during a migration.

The important design choice is where the version belongs. A version should identify an incompatible contract, not every deployment. Start with one version, document compatible additions, and introduce another only when clients need different semantics.

Choosing where the version belongs

Media-type versioning represents each contract with a vendor-specific media type, such as application/vnd.javawhizz.order.v1+json. The client requests /api/orders/{id} with that value in its Accept header, and the server returns the corresponding shape without changing the URL.

URI versioning, such as /api/v1/orders/{id}, is easy to inspect and cache, but it repeats version markers across paths. A custom X-API-Version header is also straightforward, yet it is not HTTP content negotiation and may be handled inconsistently by gateways. Choose one primary scheme and document its fallback rather than accepting several version signals simultaneously.

Defining versioned media types

Use stable names for media types and put them in constants so controllers, tests, and API documentation agree. The +json suffix identifies the serialisation, while the vendor and version tokens identify the contract.

public final class ApiMediaTypes {
    public static final String ORDER_V1 =
            "application/vnd.javawhizz.order.v1+json";
    public static final String ORDER_V2 =
            "application/vnd.javawhizz.order.v2+json";

    private ApiMediaTypes() {
    }
}

Create a new media type when a field is removed, renamed, or changes meaning. Purely additive optional fields can usually remain in the existing contract, provided clients understand how they behave. Publish supported values in OpenAPI and release notes so integrators do not guess from response samples.

Mapping versions with Spring MVC

Spring MVC selects a handler through its produces condition. Two mappings can share the same path because their producible media types differ, allowing the Accept header to determine the match.

@RestController
@RequestMapping("/api/orders")
class OrderController {
    private final OrderService orderService;

    OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping(path = "/{id}", produces = ApiMediaTypes.ORDER_V1)
    OrderV1 orderV1(@PathVariable String id) {
        return orderService.findV1(id);
    }

    @GetMapping(path = "/{id}", produces = ApiMediaTypes.ORDER_V2)
    OrderV2 orderV2(@PathVariable String id) {
        return orderService.findV2(id);
    }
}

Keep each version in its own DTO, such as OrderV1 and OrderV2, while sharing the service that loads the order. This prevents a database entity or Jackson configuration from accidentally becoming a public contract.

Avoid Accept: */* during a migration because it can match either mapping. Send an exact versioned value, return 406 Not Acceptable for unsupported types, and decide deliberately whether application/json should map to a legacy version.

Handling clients, errors, and compatibility

Clients should send one explicit version media type and check the response Content-Type. Shared caches must distinguish representations, so include Vary: Accept or configure the CDN or reverse proxy to do it. Cached URLs remain stable, but cached payloads still depend on the request header.

Spring’s ProblemDetail support can produce application/problem+json for errors; document it alongside successful responses. Roll out the new contract beside the old one, monitor usage, and give consumers a published retirement date. Additive changes are safer than changing the meaning of an existing field.

Australian deployment and operational checks

These implementation patterns keep transport separate from business logic and make negotiation testable:

Australian deployments also need local checks around commerce, time zones, customer habits, and privacy:

The main trade-offs become clearer when each approach is compared directly:

Approach Example request Strength Limitation
URI version GET /api/v1/orders/42 Obvious and easy to cache Repeats versions across paths
Media type Accept: application/vnd.javawhizz.order.v1+json Keeps resource URLs stable Requires precise client headers
Custom header X-API-Version: 1 Simple to implement Not standard content negotiation