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

Walkthrough

Space play step click any line
Three takeaways
  1. 1Custom vendor media types let one endpoint expose multiple response versions without polluting the URL path.
  2. 2Spring routes to the handler whose produces value matches the client's Accept header, so version selection is negotiated per request.
  3. 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.

Content-negotiated API versioning in Spring — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code