Introduction
When designing scalable, maintainable, and long-term compatible REST APIs, one of the most critical decisions is versioning. In this article, I’ll walk you through two widely used strategies: versioning by URL and versioning by content negotiation, explaining their pros and cons, with real-world examples using Spring Boot 3 and OpenAPI. You’ll discover which approach fits your use case best and how to implement it correctly.
If you're interested in diving deeper into API design, architecture, and practical examples, check out my book Spring Boot 3 API Mastery on Amazon.
In the first chapter of Spring Boot 3 API Mastery, I introduce the fundamentals of REST API design, highlighting from the start a critical topic for any long-term project: versioning.
Proper API versioning is essential to ensure compatibility, evolution, and stability for consuming clients. In this article, we’ll explore two common strategies, both supported by Spring Boot 3 and well-documented in the OpenAPI specification:
- Versioning by URL
- Versioning by Content Negotiation
We'll use a POST API that inserts a book resource as our example. The request and response payloads will be the same.
The first API version uses the following payload:
components:
schemas:
BookV1:
required:
- isbn
- title
- author
type: object
properties:
isbn:
type: string
title:
type: string
author:
type: string
The second version has a payload with more detailed author information:
components:
schemas:
BookV2:
required:
- isbn
- title
- author
type: object
properties:
isbn:
type: string
title:
type: string
author:
$ref: "#/components/schemas/AuthorResponse"
AuthorResponse:
properties:
firstname:
type: string
lastname:
type: string
Using this example, we can see content negotiation versioning in action for both request and response bodies.
1. Versioning by URL: clear, explicit, effective
URL versioning is probably the most well-known and widely adopted strategy. It consists of embedding the version number directly in the endpoint path, for example:
curl --json \
'{"isbn": "978-9365898088",
"title": "Spring Boot 3 API Mastery",
"author": "Vincenzo Racca"}' \
localhost:8080/api/books/v1 | jq
{
"isbn": "978-9365898088",
"title": "Spring Boot 3 API Mastery",
"author": "Vincenzo Racca"
}
curl --json \
'{"isbn": "978-9365898088",
"title": "Spring Boot 3 API Mastery",
"author": { "firstname": "Vincenzo", "lastname": "Racca"}}' \
localhost:8080/api/books/v2 | jq
{
"isbn": "978-9365898088",
"title": "Spring Boot 3 API Mastery",
"author": {
"firstname": "Vincenzo",
"lastname": "Racca"
}
}
OpenAPI Example
/api/books/v1:
post:
tags:
- book
summary: Insert a new book
operationId: insertBookV1
requestBody:
content:
application/json:
schema:
$ref: './schemas-v1.yml#/components/schemas/BookV1'
responses:
'200':
description: Book inserted
content:
application/json:
schema:
$ref: './schemas-v1.yml#/components/schemas/BookV1'
/api/books/v2:
post:
tags:
- book
summary: Insert a new book
operationId: insertBookV2
requestBody:
content:
application/json:
schema:
$ref: './schemas-v2.yml#/components/schemas/BookV2'
responses:
'200':
description: Book inserted
content:
application/json:
schema:
$ref: './schemas-v2.yml#/components/schemas/BookV2'
In Spring Boot
@RestController
public class BookController {
@PostMapping("/api/books/v1")
public ResponseEntity<BookV1> insertBookV1(@RequestBody BookV1 request) {
return ResponseEntity.ok(request);
}
@PostMapping("/api/books/v2")
public ResponseEntity<BookV2> insertBookV2(@RequestBody BookV2 request) {
return ResponseEntity.ok(request);
}
}
Pros
- Clear visibility of the requested version.
- Easily manageable in gateways, logs, and documentation.
- Natively supported by tools like OpenAPI Generator.
Cons
- Less "RESTful" according to purists: the URL should represent a resource, not a version.
- Code structure can become verbose and redundant with multiple versions.
2. Versioning by Content Negotiation: elegant, but more complex
This strategy leverages HTTP content negotiation. The client specifies:
- The desired response payload version in the
Accept
header. - The desired request payload version in the
Content-Type
header.
curl --json \
'{"isbn": "978-9365898088",
"title": "Spring Boot 3 API Mastery",
"author": "Vincenzo Racca"}' \
-H "Content-Type: application/vnd.vincenzoracca.v1+json" \
-H "Accept: application/vnd.vincenzoracca.v1+json" \
localhost:8080/api/books | jq
curl --json \
'{"isbn": "978-9365898088",
"title": "Spring Boot 3 API Mastery",
"author": { "firstname": "Vincenzo", "lastname": "Racca"}}' \
-H "Content-Type: application/vnd.vincenzoracca.v2+json" \
-H "Accept: application/vnd.vincenzoracca.v2+json" \
localhost:8080/api/books | jq
OpenAPI Example
paths:
/api/books:
post:
tags:
- book
summary: Insert a new book
operationId: insertBook
requestBody:
content:
application/vnd.vincenzoracca.v1+json:
schema:
$ref: './schemas-v1.yml#/components/schemas/BookV1'
application/vnd.vincenzoracca.v2+json:
schema:
$ref: './schemas-v2.yml#/components/schemas/BookV2'
responses:
'200':
description: Book inserted
content:
application/vnd.vincenzoracca.v1+json:
schema:
$ref: './schemas-v1.yml#/components/schemas/BookV1'
application/vnd.vincenzoracca.v2+json:
schema:
$ref: './schemas-v2.yml#/components/schemas/BookV2'
In Spring Boot
@RestController
public class BookController {
@PostMapping(
value = "/api/books",
produces = {"application/vnd.vincenzoracca.v1+json"},
consumes = "application/vnd.vincenzoracca.v1+json")
public ResponseEntity<BookV1> insertBookV1cn(@RequestBody BookV1 request) {
return ResponseEntity.ok(request);
}
@PostMapping(
value = "/api/books",
produces = "application/vnd.vincenzoracca.v2+json",
consumes = "application/vnd.vincenzoracca.v2+json")
public ResponseEntity<BookV2> insertBookV2cn(@RequestBody BookV2 request) {
return ResponseEntity.ok(request);
}
}
Pros
- Keeps endpoints clean and aligned with REST semantics.
- Greater flexibility in version management.
Cons
- May require more configuration in non-native frameworks (not an issue with Spring Boot).
- Less transparent: the version isn’t visible in the URL.
- Not always supported by gateways/API proxies.
- Not supported by OpenAPI Generator (ISSUE #11000).
Which strategy to choose?
As suggested in the book, it largely depends on your context:
- For public APIs with many external integrations, URL versioning is often preferable due to its clarity and ease of integration.
- For private APIs or a more elegant architecture, content negotiation may offer more flexibility.
In any case, documenting versions properly is crucial — and this is where OpenAPI shines, allowing formal representation of multiple versions and their media types.
Conclusion
Versioning isn’t just a technical detail — it’s a key part of evolving your APIs. Whatever strategy you choose, be consistent and transparent with the developers who will consume your services.
For more on API design, check out the first chapter of my book Spring Boot 3 API Mastery.
I also recommend reading Zalando’s guidelines: Zalando RESTful API and Event Guidelines.