Java & Spring Boot6 min read·By Liyabona Saki··

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:

  • POST returns 201 with a Location header, not 200 with the body and no location.
  • The path id is UUID, not a database-sequential Long. 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:

java
@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.

xml
<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 Location for 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

View repo →
#Spring Boot#REST#API Design#Java

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