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:
- Map each version to a dedicated DTO and keep conversion in one service layer.
- Test exact
Acceptvalues, unsupported types, and missing headers with MockMvc. - Publish media types in OpenAPI with a request and response example.
- Add
Vary: Acceptat the edge and verify cached responses separately. - Retire old contracts through published dates, usage metrics, and client outreach.
Australian deployments also need local checks around commerce, time zones, customer habits, and privacy:
- Run health checks across Sydney and Melbourne to expose regional latency and failover issues.
- Store prices in integer cents with an
AUDcurrency code, keeping GST treatment explicit. - Use
Australia/SydneyandAustralia/Melbournetime zones so daylight-saving changes remain correct. - Test compact JSON responses on mobile connections, since many customers check orders during commutes.
- Follow the Australian Privacy Principles under the Privacy Act 1988, minimising personal data in logs.
- Prepare a Notifiable Data Breaches process and test local payment providers such as Stripe, PayPal, Afterpay, and Zip without logging sensitive details.
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 |