Building REST APIs with Spring Boot

API Versioning & Best Practices

18 min Lesson 9 of 13

API Versioning & Best Practices

You have built a working CRUD REST API. Now comes the professional craft: how do you evolve that API over time without breaking existing clients? And how do you name and structure it so that it stays maintainable as it grows? This lesson covers the three mainstream versioning strategies, the naming conventions that make an API self-documenting, and a brief look at HATEOAS — the constraint that takes REST to its purest form.

Why Versioning Matters

Once clients depend on your API, any breaking change — removing a field, renaming a property, changing a status code's meaning — can break them silently or noisily. Versioning gives you a way to introduce those changes on a new version while keeping the old one alive until clients migrate. A version is a contract: everything within a version behaves consistently.

What counts as a breaking change? Removing or renaming a JSON field, changing a field's data type, removing an endpoint, changing required request parameters, and altering error-response shapes are all breaking. Adding optional fields or new endpoints is generally safe (non-breaking).

Strategy 1 — URI Path Versioning

The version number lives directly in the URL path: /api/v1/products, /api/v2/products. This is the most visible strategy and the most common in public APIs (Twitter, GitHub, Stripe all use it).

In Spring Boot you implement it with nothing more than a prefix on your @RequestMapping:

// V1 controller — existing contract @RestController @RequestMapping("/api/v1/products") public class ProductControllerV1 { @GetMapping("/{id}") public ResponseEntity<ProductDtoV1> getById(@PathVariable Long id) { // ... return ResponseEntity.ok(productService.findByIdV1(id)); } } // V2 controller — new shape (e.g. price is now a Money object, not a BigDecimal) @RestController @RequestMapping("/api/v2/products") public class ProductControllerV2 { @GetMapping("/{id}") public ResponseEntity<ProductDtoV2> getById(@PathVariable Long id) { return ResponseEntity.ok(productService.findByIdV2(id)); } }

The two controllers share the same service layer; only the DTO shapes and the URL prefix differ. Keep service logic in one place — duplication lives at the controller/DTO layer only.

Organise by version in your package structure: com.example.api.v1.controller, com.example.api.v2.controller. This keeps both versions navigable side-by-side and makes it obvious when a version is safe to delete.

Strategy 2 — Request Header Versioning

The version is passed in a custom HTTP header — commonly X-API-Version: 2 or an Accept header with a custom media type. The URL stays clean (/api/products) and a single endpoint method can branch on the header, or you can use Spring's headers attribute on @GetMapping:

@RestController @RequestMapping("/api/products") public class ProductController { // Handles: GET /api/products/{id} X-API-Version: 1 @GetMapping(value = "/{id}", headers = "X-API-Version=1") public ResponseEntity<ProductDtoV1> getByIdV1(@PathVariable Long id) { return ResponseEntity.ok(productService.findByIdV1(id)); } // Handles: GET /api/products/{id} X-API-Version=2 @GetMapping(value = "/{id}", headers = "X-API-Version=2") public ResponseEntity<ProductDtoV2> getByIdV2(@PathVariable Long id) { return ResponseEntity.ok(productService.findByIdV2(id)); } }

Header versioning keeps URLs tidy but has a real downside: URLs are no longer independently bookmarkable or cacheable by a CDN without extra Vary configuration. It also makes versioning invisible in browser address bars and in logs unless you specifically log headers.

Strategy 3 — Media-Type (Accept Header) Versioning

This is the RESTfully purest approach: the client specifies the exact representation it wants via the Accept header using a custom vendor media type, and Spring routes to the correct handler via produces:

@RestController @RequestMapping("/api/products") public class ProductController { @GetMapping( value = "/{id}", produces = "application/vnd.myapp.product.v1+json" ) public ResponseEntity<ProductDtoV1> getByIdV1(@PathVariable Long id) { return ResponseEntity.ok(productService.findByIdV1(id)); } @GetMapping( value = "/{id}", produces = "application/vnd.myapp.product.v2+json" ) public ResponseEntity<ProductDtoV2> getByIdV2(@PathVariable Long id) { return ResponseEntity.ok(productService.findByIdV2(id)); } }
Media-type versioning is elegant in theory but painful in practice. Most HTTP clients, API gateways, and developer tools default to Accept: */*, so you must document the exact media type string, test it explicitly, and ensure your gateway does not strip custom Accept headers. Many teams reach for it and switch back to URI versioning when onboarding friction rises.

Choosing a Strategy — Trade-offs at a Glance

  • URI versioning — Highly visible, trivially cacheable, easy to test in a browser. Slightly less "RESTful" because the URL should identify a resource, not a version. Best choice for public or large-scale APIs.
  • Header versioning — Clean URLs, invisible to CDN caches without extra Vary headers, harder to test without a client that sets custom headers. Good for internal APIs with controlled clients.
  • Media-type versioning — Most aligned with HTTP semantics. High developer friction for consumer teams; avoid for public APIs.

For most teams, URI versioning is the pragmatic default. GitHub, Stripe, Twilio, and the majority of mature public APIs use it for exactly that reason.

REST API Naming Conventions

Good naming makes an API self-documenting. These rules are widely adopted across the industry:

  • Use nouns, not verbs/products, not /getProducts. The HTTP method supplies the verb.
  • Plural collection names/users, /orders. A collection is a set of resources.
  • Lowercase, hyphen-separated words/product-categories, not productCategories or product_categories. URLs are case-sensitive on some servers; lowercase removes ambiguity.
  • Hierarchy reflects relationships/users/{userId}/orders/{orderId}. Nest sub-resources under their parent, but do not go deeper than two or three levels or URLs become unwieldy.
  • Filter, sort and paginate via query params/products?category=electronics&sort=price&page=2&size=20. Keep the path clean; the path is the resource identity, query params are optional constraints.
  • Use standard HTTP status codes consistently201 Created with a Location header after a POST, 204 No Content on a successful DELETE, 404 Not Found when a resource does not exist (not 200 with an error body).
Document your error shape and stick to it. A consistent error body — for example { "status": 404, "error": "Not Found", "message": "Product 42 not found", "timestamp": "..." } — lets clients write one error-handler for your whole API instead of guessing the shape per endpoint.

A Note on HATEOAS

REST as defined by Roy Fielding includes a constraint called Hypermedia As The Engine Of Application State (HATEOAS). In a HATEOAS API, every response includes links that tell the client what it can do next — so the client does not need to hard-code URLs, it discovers them at runtime:

// HATEOAS response for GET /api/v1/orders/99 { "id": 99, "status": "PENDING", "total": 149.99, "_links": { "self": { "href": "/api/v1/orders/99" }, "confirm": { "href": "/api/v1/orders/99/confirm", "method": "POST" }, "cancel": { "href": "/api/v1/orders/99/cancel", "method": "DELETE" }, "customer":{ "href": "/api/v1/customers/7" } } }

Spring provides the Spring HATEOAS library (starter: spring-boot-starter-hateoas) with EntityModel, CollectionModel, and WebMvcLinkBuilder to construct these link structures without manually building strings.

import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; @GetMapping("/{id}") public EntityModel<OrderDto> getOrder(@PathVariable Long id) { OrderDto order = orderService.findById(id); return EntityModel.of(order, WebMvcLinkBuilder.linkTo( WebMvcLinkBuilder.methodOn(OrderController.class).getOrder(id) ).withSelfRel(), WebMvcLinkBuilder.linkTo( WebMvcLinkBuilder.methodOn(OrderController.class).confirmOrder(id) ).withRel("confirm") ); }

HATEOAS makes the API truly self-describing and decouples clients from hard-coded URL structures. In practice, most teams building private or partner-facing APIs skip it because the tooling overhead is significant and most clients do not dynamically follow links. It is most valuable in public APIs with many heterogeneous consumers. Knowing it exists — and recognising the _links pattern when you see it — is the important takeaway here.

Summary

URI path versioning (/api/v1/) is the pragmatic, industry-standard choice for most APIs. Header and media-type strategies trade URL clarity for HTTP purity at the cost of client complexity. Solid naming conventions — plural nouns, lowercase-hyphen paths, verbs from HTTP methods, query params for filtering — turn a working API into a well-designed one. HATEOAS is the REST ideal worth understanding, even if you do not implement it on day one. In the final lesson of this tutorial you will bring all of these skills together to build a complete, production-quality REST API from scratch.