Abbeal

Architecture

Versioning a REST API without touching the URLs, with Spring 7

Why I chose content negotiation over a /v1 in the URL, and how Spring Framework 7 sets it up in three properties.

6 min

The problem: clients that call the API directly

I maintain a small personal REST API on Spring Boot 4, so Spring Framework 7. Several clients call it directly: AI agents, scripts, a few integrations. They hit routes like /api/shorten or /api/random as is.

No endpoint was versioned. As long as nothing changes, that's fine. The day I want to change a response format, for example returning a JSON object instead of a plain string, I break all those clients at once.

So I needed a way to ship a version 2 without breaking version 1. The real question: how does a client choose the version it gets?

Three options on the table

A prefix in the URL (/api/v1/...) is the most common reflex. It is explicit and easy to route. But it breaks every existing call, unless you keep aliases for each route. And it quickly pushes you to duplicate controllers and DTOs in v1 and v2 packages.

A custom header (X-API-Version) or a query parameter (?version=1) keeps the URLs intact. That's better. But you are inventing a mechanism when HTTP already has one.

Content negotiation uses exactly that standard mechanism. The client asks for a specific representation in the Accept header:

http
Accept: application/json;version=1

The URL doesn't move. The version is part of the requested media type, which is exactly what it is for.

The choice, and why it costs almost nothing

I went with content negotiation for a simple reason: no client has to migrate. A request without a version gets version 1, as before.

The good surprise is Spring Framework 7. API versioning is built in. No interceptor to write, no path constants. Three lines in application.properties:

properties
spring.mvc.apiversion.supported=1.0 spring.mvc.apiversion.default=1.0 spring.mvc.apiversion.use.media-type-parameter[application/json]=version

The first lists the accepted versions. The second serves 1.0 when the client doesn't specify anything. The third tells Spring to read the version from the version parameter of the application/json type.

HTML pages (Thymeleaf) are not affected: the rule only applies to application/json.

What the client sees

On the client side, a request without a version goes through normally:

bash
curl -H "Accept: application/json" "https://api.example.com/api/random?min=1&max=6" # {"value":6,"min":1,"max":6,"timestamp":"..."}

An unknown version returns a 400 error, in the same format as the other API errors:

bash
curl -H "Accept: application/json;version=9" "https://api.example.com/api/random?min=1&max=6" # {"status":400,"error":"Bad Request","message":"Invalid API version: '9.0.0'.","path":"/api/random"}

Two details. First, it's a 400 and not a 406: Spring throws an InvalidApiVersionException, which my global error handler turns into a standard response. Second, Spring normalizes the version: 9 becomes 9.0.0 in the message. Worth knowing before writing tests on the exact text.

What version 2 will look like

When a breaking change comes, I'll add a method next to the existing one, in the same controller:

kotlin
@GetMapping("/shorten") // version 1, implicite fun shorten(@RequestParam url: String): String = ... @GetMapping("/shorten", version = "2") // Accept: application/json;version=2 fun shortenV2(@RequestParam url: String): ShortenResponseV2 = ...

No v2 package, no duplicated controller. I'll only need to add 2.0 to spring.mvc.apiversion.supported.

Testing it

A @WebMvcTest covers the behavior, on a JSON endpoint and an HTML page. Five cases are enough:

  • without a version, the request is served as version 1
  • with version=1, it is served
  • with an unknown version (version=9), 400 response
  • with a malformed version (version=abc), 400 response
  • an HTML page responds normally
kotlin
@Test fun unsupportedVersionReturns400() { mockMvc.perform(get("/api/random").accept("application/json;version=9")) .andExpect(status().isBadRequest) .andExpect(jsonPath("$.status").value(400)) }

What's left for the day v2 arrives

I deliberately postponed three topics, because nothing needs them while there is only one version.

Caching, first. My API sits behind a CDN. With two versions on the same URL, a shared cache has to know that the response depends on the Accept header. Spring doesn't add Vary: Accept by itself: that will have to be done.

Deprecation, next. Spring 7 can announce the end of a version with the Deprecation, Sunset and Link headers. Useful the day version 1 has to go.

OpenAPI Generator, finally. It handles several media types for the same path poorly (issue #11000 in the project). With no published spec for now, it doesn't bother me. Something to watch if I publish one.

What I take away

The result is very positive for a minimal cost: three properties, no migration, no duplicated URL. Versioning is in place before it is even needed, and it gets in nobody's way.

There are two trade-offs to know about. The version no longer shows up in URLs or access logs: if I need it for troubleshooting, I'll have to log it explicitly. And content negotiation asks for a bit more rigor on caching as soon as a second version exists.

If your API already has clients and you're on Spring Boot 4, it's in my view the simplest choice today. Everything is recorded in an ADR in the project, with the rejected options, so the decision stays readable six months from now.

Working on something similar?

Talk to an architect