java
42 lines · 7 steps
Content-negotiated API versioning in Spring
One URL serves two response shapes by matching each request's Accept header to a versioned media type.
Explained by
highlit
1@RestController
2@RequestMapping("/api/orders")
3public class OrderController {
4
5 private final OrderService orderService;
6
7 public OrderController(OrderService orderService) {
8 this.orderService = orderService;
9 }
10
11 @GetMapping(value = "/{id}", produces = "application/vnd.acme.order.v1+json")
12 public ResponseEntity<OrderV1Response> getOrderV1(@PathVariable Long id) {
13 Order order = orderService.findById(id);
14 OrderV1Response body = new OrderV1Response(
15 order.getId(),
16 order.getCustomerName(),
17 order.getTotal()
18 );
19 return ResponseEntity.ok()
20 .contentType(MediaType.valueOf("application/vnd.acme.order.v1+json"))
21 .body(body);
22 }
23
24 @GetMapping(value = "/{id}", produces = "application/vnd.acme.order.v2+json")
25 public ResponseEntity<OrderV2Response> getOrderV2(@PathVariable Long id) {
26 Order order = orderService.findById(id);
27 OrderV2Response body = new OrderV2Response(
28 order.getId(),
29 new CustomerSummary(order.getCustomerId(), order.getCustomerName()),
30 Money.of(order.getTotal(), order.getCurrency()),
31 order.getStatus()
32 );
33 return ResponseEntity.ok()
34 .contentType(MediaType.valueOf("application/vnd.acme.order.v2+json"))
35 .body(body);
36 }
37
38 @ExceptionHandler(OrderNotFoundException.class)
39 public ResponseEntity<Void> handleNotFound() {
40 return ResponseEntity.notFound().build();
41 }
42}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Custom vendor media types let one endpoint expose multiple response versions without polluting the URL path.
- 2Spring routes to the handler whose produces value matches the client's Accept header, so version selection is negotiated per request.
- 3A single @ExceptionHandler centralizes error mapping for every method in the controller.
Related explainers
typescript
import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
java
@Component @Converter public class EncryptedStringConverter implements AttributeConverter<String, String> {
Transparent column encryption in Spring & JPA
encryption
aes-gcm
jpa-converter
Advanced
10 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
8 steps
java
package com.acme.billing.config; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties;
Feature-flagged beans with Spring @ConditionalOnProperty
feature-flags
conditional-beans
strategy-pattern
Intermediate
5 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core'; interface Preferences { theme: 'light' | 'dark';
A signal-based preferences store in Angular
signals
state-management
persistence
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/content-negotiated-api-versioning-in-spring-explained-java-6956/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.