← Back to the section

Once an API ships to production, it gains clients. Mobile apps, partners, your own frontend — they all rely on a particular contract. Change a field's format or remove an endpoint, and something somewhere breaks.

Versioning is how you evolve an API without breaking those who already use it.

Version in the URL: why this way

The most common approach is to put the version right in the path:

/api/v1/orders
/api/v2/orders

This is convenient for several reasons: the version is visible in logs, in the browser, in the documentation. The request curl http://localhost/api/v1/orders speaks for itself — you don't need to add headers to know which version you're calling.

There are two popular alternative approaches that create problems in practice:

Version in a query parameter (/orders?version=1) breaks caching. Proxies and CDNs build the cache key from the URL. If the URL is the same for different versions, a client may get a response from the wrong version out of the cache.

Version in a header (Accept-Version: v1) hides the version. It isn't visible in logs, it doesn't show up in the browser's address bar, and routing at the proxy becomes harder.

That's why — version in the URL path. Format: the letter v plus an integer. Integer only: v1, v2. No minor versions, no dates:

/api/v1/orders         ✓
/api/v2/orders         ✓

/api/v1.2/orders       ✗  — a minor version is not needed
/api/2024/orders       ✗  — a date-version says nothing about compatibility
/orders                ✗  — no /api and no version
/v1/orders             ✗  — no /api

Why /api? It's a namespace for business endpoints. Operational paths — /health, /metrics, /ready — live outside it.

When to create a new version

There's one rule: a new version is created only on a breaking change. If a change is backward compatible, it goes into the current version.

At first glance it seems like any change breaks compatibility. But that's not so — there's a large class of changes that clients are obligated to survive without breaking.

The key convention: a client must ignore unknown fields and unknown enum values in a response. This is the foundation of forward compatibility.

If a client is configured to fail when it sees an unfamiliar field (for example, Jackson with failOnUnknownProperties: true), it will break every time a field is added to the API. That's the client's problem, not the API's. Jackson and Spring RestClient ignore unknown fields by default — that's the right default.

What counts as a breaking change

A breaking change is a change that breaks existing clients without any edits on their side:

  • Remove or rename a field — the client used to read customerId, now it's gone.
  • Change a field's type — it was a string, now it's a number. The client parses it differently or crashes.
  • Remove a value from an enum — the client receives a status that no longer exists in the enumeration.
  • Add a required parameter — a request without it starts failing with an error.
  • Change the HTTP method — it was POST /orders, now it's PUT /orders.
  • Change the URL/orders became /sales-orders.
  • Change the response code — it was 200, now it's 201. Clients that check for the exact code will break.
  • Change a field's semantics — the total field used to include taxes, now it doesn't. Same data, different meaning.
  • Tighten validationmaxLength was reduced, and requests that used to pass now get rejected.
  • Remove an endpoint — the client calls it and gets a 404.

What does not break compatibility

These changes can be made in the current version without a bump:

  • Add an optional field to a response — the client ignores it if it doesn't know about it.
  • Add an optional query parameter — old clients don't send it, and that's fine.
  • Add a new value to an enum — a client that handles unknown enum values correctly won't break.
  • Add a new endpoint — nobody is forced to call it.
  • Relax validation — requests that used to be rejected now pass. That's only better for the client.
  • Add a new error code — clients that don't know this code should have an "unknown error" path.
  • Improve the text of an error message — the detail field changed, the meaning is the same.

A common mistake: creating a v2 just to add an optional field. That's unnecessary — both clients, v1 and v2, will get the same response, and you'll only take on the burden of maintaining two versions for no reason.

How to run v1 and v2 in parallel

When a breaking change really is needed:

  1. You create v2 with the new contract.
  2. v1 keeps working unchanged.
  3. You tell clients that v1 will be retired — through the Sunset header in responses, through the documentation.
  4. Once clients have migrated (usually 6–12 months) — you remove v1.

In Spring Boot this looks straightforward:

@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
    // old contract
}

@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
    // new contract
}

Under the hood both controllers can use the same use case objects — only the DTOs and the mapping differ. This way v2 doesn't duplicate business logic, it merely presents it in a new format.

In short

  • Version in the URL path: /api/v1/orders. Format — v plus an integer, no minor versions and no dates.
  • The /api prefix is mandatory for business endpoints; /health, /metrics live outside it.
  • A new version is created only on a breaking change. Non-breaking changes go into the current version.
  • A client must ignore unknown fields and enum values in a response — that's the basis of forward compatibility.
  • Breaking: removing/renaming a field, changing a type, removing an enum value, changing the HTTP method, URL, response code, tightening validation.
  • Non-breaking: adding an optional field, a new enum value, a new endpoint, relaxing validation.
  • Version in a query (?version=1) and in a header are not used.
  • v1 and v2 live in parallel; v1 is retired with a Sunset header after clients migrate.