Building REST APIs with Spring Boot: A Complete Guide
Design and build a production-ready REST API with Spring Boot — proper layering, DTOs, validation, error handling and testing.
REST is not a religion — it is a contract
I have reviewed maybe two hundred Spring Boot REST APIs over the last few years. The ones that age well are not the ones with the purest HATEOAS or the cleverest verb usage. They are the ones with a *consistent error contract*, *versioning that the team actually follows*, and a *response shape that frontend engineers can predict without reading docs*.
This is a practical guide to the decisions I make when starting a Spring Boot REST service today.
The three decisions that matter on day one
Before you write a controller, pick:
1. How errors look on the wire. Pick RFC 7807 (Problem Details) and never deviate. Spring Boot 3 supports it natively.
2. How versioning works. URL path (/v1/orders) is uncool but operationally simple. Use it.
3. What goes in the response envelope. Either { data, meta } for everything or raw objects — pick one. Mixing both is the most common cause of frontend pain.
I will defend the boring choice every time. Consistency at the API edge is worth more than elegance.
A controller worth writing
```java
@RestController
@RequestMapping("/v1/orders")
@Validated
class OrderController {private final OrderService orders;
@GetMapping("/{id}") ResponseEntity<OrderResponse> get(@PathVariable UUID id) { return orders.find(id) .map(OrderResponse::from) .map(ResponseEntity::ok) .orElseThrow(() -> new NotFoundException("order", id)); }
@PostMapping ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest req) { var created = orders.create(req); return ResponseEntity .created(URI.create("/v1/orders/" + created.id())) .body(OrderResponse.from(created)); } } ```
Things this gets right that I see violated constantly:
POSTreturns 201 with aLocationheader, not 200 with the body and no location.- The path id is
UUID, not a database-sequentialLong. Sequential ids leak business metrics ("oh, they have 4,000 orders") and enumerate easily. - Request and response DTOs are records, not entities. Returning a JPA entity directly is the #1 way to accidentally expose a column you added last week.
The error contract
In a @ControllerAdvice:
```java
@ExceptionHandler(NotFoundException.class)
ResponseEntity<ProblemDetail> notFound(NotFoundException e) {
var p = ProblemDetail.forStatus(404);
p.setTitle("Resource not found");
p.setDetail(e.getMessage());
p.setType(URI.create("https://errors.example.com/not-found"));
return ResponseEntity.status(404).body(p);
}@ExceptionHandler(MethodArgumentNotValidException.class) ResponseEntity<ProblemDetail> invalid(MethodArgumentNotValidException e) { var p = ProblemDetail.forStatus(422); p.setTitle("Validation failed"); p.setProperty("errors", e.getFieldErrors().stream() .map(f -> Map.of("field", f.getField(), "reason", f.getDefaultMessage())) .toList()); return ResponseEntity.status(422).body(p); } ```
Two things to notice: 422 (Unprocessable Entity) for validation, not 400. 400 is for malformed JSON; 422 is for "I understood it but it broke a business rule". The distinction matters to clients writing retry logic.
And the type field is a URL to a human-readable error doc. You do not need to host the docs immediately — but reserving the URL space means clients can build error catalogs from day one.
Pagination — the single most argued-about decision
Offset pagination (?page=3&size=20) is what every junior engineer reaches for. It is also broken on any list that allows inserts during pagination — items can disappear or duplicate.
Cursor pagination (?after=<opaque-cursor>&limit=20) is correct but harder to implement.
My pragmatic rule: offset for admin tools and search results (where users browse a few pages and leave). Cursor for anything a machine will iterate (exports, sync jobs). If you only pick one, pick cursor — it scales and it is correct.
Idempotency for POST — the bit nobody talks about
The Stripe-style Idempotency-Key header is the single feature most likely to save you a 3am page. Implementation:
@PostMapping
ResponseEntity<OrderResponse> create(
@RequestHeader("Idempotency-Key") UUID key,
@Valid @RequestBody CreateOrderRequest req) {
return idempotency.executeOnce(key, () -> orders.create(req));
}
Where executeOnce stores the response keyed by the header for 24 hours and returns the cached response on retry. Without this, a client that times out and retries can create duplicate orders. With it, retries are safe by construction.
Validation that does not lie
@NotNull on a DTO field plus @Valid on the controller parameter is the basic version. The non-obvious part is validating cross-field invariants (startDate < endDate) — those do not belong in @Constraint annotations because the error message ends up attached to one of the two fields arbitrarily. Move them into the service layer and throw a typed exception that the advice maps to 422.
Documenting it without writing docs
springdoc-openapi reads your controllers and produces an OpenAPI 3 spec automatically. Wire it up before you have a single endpoint — the team starts treating the generated spec as the contract, and that habit compounds.
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
What I would not bother with on a new API
- HATEOAS links in responses. Nobody follows them. Send the URL in
Locationfor created resources and stop there. - Custom media types like
application/vnd.acme.order+json. Theoretically pure, practically a tax on every client that just wants JSON. - A "BFF" layer in front of one frontend. You do not need it until you have a second client with a meaningfully different shape.
The discipline that makes a REST API good is dull: one error format, one envelope decision, idempotent writes, and a generated spec the team treats as the source of truth. Most failures I see are not technical — they are the team not agreeing on the boring decisions on day one and being unable to walk them back at day three hundred.
Go deeper
Further reading
Source Code
Get the full project on GitHub
Stay in the Loop
Get the next tutorial in your inbox
Continue reading in Java & Spring Boot →
API Rate Limiting in Spring Boot with Bucket4j and Redis
Protect your APIs from abuse with per-user and per-IP rate limiting using Bucket4j, Redis and a clean filter-based implementation.
Related tutorials
API Rate Limiting in Spring Boot with Bucket4j and Redis
Protect your APIs from abuse with per-user and per-IP rate limiting using Bucket4j, Redis and a clean filter-based implementation.
Spring Boot + Kafka — Build a Real-Time Messaging System
Produce and consume Kafka messages from Spring Boot with proper serialization, error handling and consumer groups.
Spring Boot + Redis Caching — Make Your API 10× Faster
Cache hot reads with Redis and Spring's @Cacheable in minutes. Includes TTLs, eviction and key design tips.
