Designing REST APIs in Spring Boot: Versioning, OpenAPI & Contracts

An API is a contract, and in a large enterprise that contract is consumed by teams you will never meet and clients you cannot redeploy. Designing it well — predictable resources, honest status codes, evolution without breakage, and machine-readable documentation — is what separates an API that scales across an organization from one that generates a constant stream of integration tickets. This deep dive covers REST API design in Spring Boot: modeling, status codes, versioning, OpenAPI, errors, pagination, and idempotency.

TL;DR: Model resources as nouns with the right HTTP methods and status codes. Treat backward compatibility as the goal and versioning as the fallback — URI versioning (/v1) is the pragmatic enterprise default. Generate OpenAPI docs from code with springdoc. Return a consistent error format (RFC 9457 Problem Details), paginate consistently, and make unsafe operations idempotent.
Tailor your resume to an API / backend role →
flowchart LR
  Cl[Client] --> GW[API Gateway]
  GW -->|/v1/orders| V1[Orders API v1]
  GW -->|/v2/orders| V2[Orders API v2]
  V1 --> Spec[OpenAPI spec]
  V2 --> Spec
        
URI versioning routes clients to the right API version; OpenAPI documents each one.

Resource modeling and HTTP methods

REST models resources (nouns), not actions. Use plural nouns and let HTTP methods express the verb: GET /orders, POST /orders, GET /orders/42, PUT/PATCH /orders/42, DELETE /orders/42. Nest only to express ownership (/orders/42/items) and keep nesting shallow. Resist the urge to put verbs in paths (/createOrder) — the method already is the verb.

Respect method semantics: GET is safe (no side effects) and cacheable; PUT and DELETE are idempotent (repeating them yields the same state); POST is neither. These properties drive caching, retries, and resilience behavior throughout the stack, so honoring them is not pedantry.

Status codes that tell the truth

CodeUse for
200 / 201 / 204OK / created (return Location) / success with no body
400Malformed or invalid request
401 / 403Not authenticated / authenticated but not allowed
404Resource doesn’t exist
409Conflict (e.g. optimistic-lock or duplicate)
422Well-formed but semantically invalid
429Rate limited
5xxServer fault — never use to signal a client error

The cardinal sin is returning 200 OK with an error in the body. Clients, caches, and gateways rely on the status code; lying in it breaks retries and monitoring.

Evolution first, versioning second

The cheapest version is the one you never cut. Most changes can be made backward-compatible: add new optional fields and endpoints, never remove or rename existing fields, never change a field’s type or meaning, and never tighten validation on existing inputs. Tell clients to ignore unknown fields (be liberal in what you accept). If you hold that line, consumers keep working as you evolve.

When you truly must break compatibility, version. The common strategies:

StrategyExampleTrade-off
URI/v1/ordersExplicit, easy to route/cache — the enterprise default
Header / media typeAccept: application/vnd.acme.v2+jsonCleaner URLs, harder to test and operate
Query param/orders?version=2Simple but easy to misuse and cache poorly

URI versioning wins in most large orgs because it’s visible in logs, trivial to route at the gateway, and cache-friendly. Whichever you pick, publish a deprecation policy: announce, support the old version for a defined window with Deprecation/Sunset headers, then retire it.

OpenAPI: documentation generated from code

Hand-written API docs rot. Generate them from the code with springdoc-openapi, which inspects your controllers and DTOs to produce an OpenAPI 3 spec plus interactive Swagger UI.

<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- Swagger UI at /swagger-ui.html, spec at /v3/api-docs -->
@RestController
@RequestMapping("/v1/orders")
class OrderController {

  @Operation(summary = "Create an order")
  @ApiResponse(responseCode = "201", description = "Created")
  @PostMapping
  ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrder cmd) {
    var saved = service.place(cmd);
    return ResponseEntity
        .created(URI.create("/v1/orders/" + saved.id()))
        .body(saved);
  }
}

Publishing the generated spec lets consumers auto-generate typed clients in any language — and an API-first variant inverts this: design the OpenAPI spec, then generate server interfaces from it, so the contract is the source of truth and both sides build to it.

Consistent error responses

Every endpoint should fail in the same shape so clients can handle errors generically. The standard is RFC 9457 Problem Details (application/problem+json), which Spring supports natively.

# Turn on RFC 9457 problem responses
spring.mvc.problemdetails.enabled=true
@RestControllerAdvice
class ApiExceptionHandler {
  @ExceptionHandler(OrderNotFound.class)
  ProblemDetail notFound(OrderNotFound ex) {
    var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    pd.setType(URI.create("https://errors.acme.com/order-not-found"));
    pd.setProperty("traceId", currentTraceId());   // help support correlate
    return pd;
  }
}

Include a stable machine-readable type/code, a human message, and the trace ID — so a failed call is debuggable end to end. Validate inputs with Bean Validation (@Valid) and return field-level details on 400/422.

Pagination, filtering, and large collections

Never return an unbounded list — it will eventually be huge. Paginate consistently across the API. Offset pagination (?page=2&size=20) is simple and what Spring Data’s Pageable gives you, but it drifts and slows on deep pages. Cursor (keyset) pagination (?after=<cursor>) is stable under inserts and fast at any depth — prefer it for large, frequently-changing datasets. Always return the page metadata (or links) the client needs to fetch the next page.

Idempotency for safe retries

Networks fail after a request is sent but before the response arrives, so clients retry — and a retried POST /payments can double-charge. Support an idempotency key: the client sends a unique Idempotency-Key header, the server records the first result for that key, and a retry with the same key returns the stored result instead of re-executing. This single pattern prevents a whole class of duplicate-side-effect bugs in payments, orders, and provisioning.

A few cross-cutting rules

Takeaways

A durable enterprise REST API is predictable and honest: noun resources with correct methods and status codes, backward-compatible evolution with URI versioning as the fallback, OpenAPI docs generated from code, RFC 9457 error responses carrying a trace ID, consistent pagination, and idempotency keys for safe retries. Spring Boot and springdoc give you the machinery — the engineering is in applying these conventions uniformly so every team that consumes your API has the same, boringly reliable experience.

Frequently asked questions

What is the best way to version a REST API?
There is no single best way, but URI versioning (e.g. /v1/orders) is the most common and explicit in enterprises because it is easy to route and cache. Header/media-type versioning is cleaner in theory but harder to operate. Whatever you choose, the real goal is to avoid breaking changes so you rarely need a new version.

How do I document a Spring Boot API with OpenAPI?
Add the springdoc-openapi starter; it generates an OpenAPI 3 spec from your controllers and DTOs and serves interactive Swagger UI. Enrich it with annotations for descriptions and examples, and publish the generated spec so consumers can generate clients.

Land your next Java role — tailor your resume with AI →