package com.shop.api.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.favorParameter(false)
.ignoreAcceptHeader(false)
.defaultContentType(MediaType.parseMediaType("application/vnd.shop.order.v1+json"));
}
}
package com.shop.api.web;
import org.springframework.http.MediaType;
public final class OrderMediaTypes {
public static final String V1_JSON = "application/vnd.shop.order.v1+json";
public static final String V2_JSON = "application/vnd.shop.order.v2+json";
public static final MediaType V1 = MediaType.parseMediaType(V1_JSON);
public static final MediaType V2 = MediaType.parseMediaType(V2_JSON);
private OrderMediaTypes() {
}
}
package com.shop.api.web;
import com.shop.api.domain.Order;
import com.shop.api.service.OrderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static com.shop.api.web.OrderMediaTypes.V1_JSON;
import static com.shop.api.web.OrderMediaTypes.V2_JSON;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
// Legacy clients (or plain application/json) resolve here.
@GetMapping(value = "/{id}", produces = {V1_JSON, APPLICATION_JSON_VALUE})
public ResponseEntity<OrderResponses.OrderV1> getOrderV1(@PathVariable long id) {
Order order = orderService.findById(id);
return ResponseEntity.ok(OrderResponses.toV1(order));
}
@GetMapping(value = "/{id}", produces = V2_JSON)
public ResponseEntity<OrderResponses.OrderV2> getOrderV2(@PathVariable long id) {
Order order = orderService.findById(id);
return ResponseEntity.ok(OrderResponses.toV2(order));
}
}
package com.shop.api.web;
import com.shop.api.domain.Order;
public final class OrderResponses {
private OrderResponses() {
}
public record OrderV1(long id, String customerName, String status, double total) {
}
public record Customer(String firstName, String lastName) {
}
public record OrderV2(long id, Customer customer, String status, double total, String currency) {
}
public static OrderV1 toV1(Order order) {
return new OrderV1(order.getId(), order.getCustomerName(), order.getStatus(), order.getTotal());
}
public static OrderV2 toV2(Order order) {
String[] parts = order.getCustomerName().split(" ", 2);
Customer customer = new Customer(parts[0], parts.length > 1 ? parts[1] : "");
return new OrderV2(order.getId(), customer, order.getStatus(), order.getTotal(), order.getCurrency());
}
}
API versioning by content negotiation keeps a single, stable URL (/api/orders/{id}) while letting clients opt into a specific representation through the Accept header. Instead of baking /v1/ or /v2/ into the path, each version is expressed as a vendor media type such as application/vnd.shop.order.v1+json. This snippet shows how Spring Boot's produces attribute on request mappings dispatches to different handler methods purely based on the requested media type, so old and new clients coexist on one endpoint.
In OrderMediaTypes, the vendor types are declared as constants and parsed into MediaType instances once, avoiding scattered string literals and giving a single source of truth. The V1_JSON and V2_JSON values follow the vnd.<vendor>.<resource>.<version>+json convention, which is the idiomatic way to encode a semantic version in a MIME type while still telling parsers the payload is JSON.
OrderController maps both versions to the same URL. The two getOrder methods differ only by their produces value, and Spring's ContentNegotiationManager routes the request to whichever method matches the client's Accept header. The default method (annotated with V1_JSON) is also marked so that a client sending a generic application/json or no preference falls back to v1, preserving backward compatibility. Each method delegates to OrderService for the domain object and then maps it to a version-specific DTO.
The DTOs in OrderResponses are where the contract actually diverges. OrderV1 exposes a flat customerName, while OrderV2 splits it into a structured customer object and adds a currency field — a breaking change that would otherwise force a new URL. Keeping these as separate immutable records means the shape of each version is explicit and cannot drift.
The main trade-off is discoverability: version-in-header is invisible in a browser address bar and harder to test with a naive curl, so tooling must set Accept deliberately. In return, URLs stay clean and resources keep one canonical identity. This pattern suits public APIs with long-lived clients where breaking changes are rare but must be introduced without stranding existing integrations. A pitfall to watch is forgetting the fallback mapping, which would return 406 Not Acceptable to legacy callers.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
Share this code
Here's the card — post it anywhere.