Block Image

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:

  1. Versioning by URL
  2. 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:

  1. The desired response payload version in the Accept header.
  2. 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.