Spring Boot
REST Controller Basics
A REST controller's job is to translate HTTP into method calls and back: read the request, hand it to a service, and shape the response. This entry covers controller anatomy, @Controller vs @RestController, the HTTP method annotations and the status codes that go with them, binding request data, ResponseEntity, caching and downloads, and error responses. It also flags the things tutorials get wrong: serializing Page directly, hand-written HEAD and OPTIONS handlers, Location headers behind a proxy, and misusing @Validated.
Anatomy of a REST Controller
Keep controllers thin. They deal with HTTP: paths, parameters, status codes, headers. Business rules live in a service. A controller with one constructor needs no @Autowired, and a final field plus Lombok's @RequiredArgsConstructor (or just a hand-written constructor) is enough.
Three details worth getting right from the start.
Pagination: returning a Spring Data Page straight from a controller serializes the internal PageImpl class, whose JSON shape is not guaranteed to stay stable. Since Spring Data 3.3 you get a warning for it. Wrap it in PagedModel, or switch the whole app to a stable DTO format with @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO).
Location header: for a 201 response, build the URL from the current request instead of hardcoding the path, so renaming the base path cannot break it. If the app runs behind a load balancer or reverse proxy, set server.forward-headers-strategy=framework, or the generated URL will contain the internal host and http scheme.
Validation: @Valid on a @RequestBody validates the object's fields. You do not need a class-level @Validated for that. On Spring Framework 6.1 and later, constraint annotations placed directly on controller parameters (like @Min on a @RequestParam) are validated automatically too, and failures surface as HandlerMethodValidationException. Adding class-level @Validated switches you to the older proxy-based mechanism, which throws a different exception (ConstraintViolationException) and needs its own handler, so skip it unless you have a reason.
Java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public PagedModel<UserResponse> findAll(@PageableDefault(size = 20) Pageable pageable) {
return new PagedModel<>(userService.findAll(pageable)); // stable JSON shape
}
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id); // 200, body is the DTO
}
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody @Valid CreateUserRequest request) {
UserResponse created = userService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri(); // .../api/v1/users/42
return ResponseEntity.created(location).body(created); // 201 + Location
}
@PutMapping("/{id}")
public UserResponse replace(@PathVariable Long id, @RequestBody @Valid UpdateUserRequest request) {
return userService.update(id, request);
}
@PatchMapping("/{id}")
public UserResponse patch(@PathVariable Long id, @RequestBody @Valid PatchUserRequest request) {
return userService.patch(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204, no ResponseEntity needed
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}@Controller vs @RestController
@Controller is the general MVC annotation: handler methods return a view name, and a ViewResolver turns it into a rendered template (Thymeleaf, Freemarker). @RestController is @Controller plus @ResponseBody on every method, so the return value is serialized straight into the response body (JSON by default).
For a JSON API use @RestController. For server-rendered pages use @Controller. You can mix by putting @ResponseBody on individual methods of a @Controller, but a controller that does both usually reads better when split in two. Note that a method returning ResponseEntity is treated as a response body even in a plain @Controller.
Java
// Server-rendered pages: return a view name
@Controller
public class PageController {
private final UserService userService;
public PageController(UserService userService) { this.userService = userService; }
@GetMapping("/users")
public String usersPage(Model model) {
model.addAttribute("users", userService.findAll());
return "users"; // resolved to templates/users.html
}
}
// JSON API: every method writes the return value to the body
@RestController // = @Controller + @ResponseBody
@RequestMapping("/api/v1/users")
public class UserApiController {
@GetMapping
public List<UserResponse> findAll() { ... } // no @ResponseBody needed
}HTTP Methods and Status Codes
Each composed annotation (@GetMapping, @PostMapping, and so on) is shorthand for @RequestMapping with a method. Pairing the right verb with the right status code is most of what makes an API predictable.
GET reads and never changes state. POST creates (or triggers an action) and returns 201 with a Location header. PUT replaces the whole resource and is idempotent: sending it twice leaves the same state. PATCH changes part of a resource. DELETE removes it and returns 204. The ones that are safe to retry are GET, PUT, and DELETE; POST is not.
You do not need to write HEAD or OPTIONS handlers. Spring answers HEAD automatically for any GET mapping, and answers OPTIONS with an Allow header built from your mappings (and handles CORS preflight when CORS is configured). Hand-writing them is redundant and can interfere with CORS.
On PATCH, a plain DTO cannot tell "field omitted" from "field explicitly set to null". If that distinction matters, use JSON Merge Patch semantics with a type that records presence (for example Jackson's JsonNullable or an Optional-based wrapper), or accept a Map and apply known keys.
Finally, since Spring 6 a mapping for /users no longer matches /users/ (trailing slash). Clients that add one will get a 404, so be consistent.
Java
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
// Content negotiation: consumes/produces narrow what the endpoint accepts and returns.
// A request with the wrong Content-Type gets 415, an unsatisfiable Accept gets 406.
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<OrderResponse> create(@RequestBody @Valid CreateOrderRequest req) { ... }
// Triggering an action that is not CRUD: POST on a sub-resource
@PostMapping("/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) { ... }
}
// Quick reference
// GET /orders 200 + list GET /orders/{id} 200, or 404 if missing
// POST /orders 201 + Location PUT /orders/{id} 200 (or 204), 404 if missing
// PATCH /orders/{id} 200 DELETE /orders/{id} 204 (404 or 204 if already gone)
// Validation failure 400 (or 422) Not authenticated 401, not allowed 403
// Duplicate / conflict 409 Wrong Content-Type 415Binding Request Data
Spring converts parts of the request into method parameters. @PathVariable reads a segment of the URL, @RequestParam reads a query-string or form value, @RequestHeader reads a header, and @RequestBody deserializes the body (it is required by default; an empty body is a 400).
@RequestParam is required unless you say otherwise, so give optional filters required = false or a defaultValue. Use a wrapper or Optional for values that may be absent, and let Spring convert types (numbers, enums, dates with @DateTimeFormat) for you: a value that cannot be converted produces a 400 before your method runs.
Use records for request and response DTOs. They are short, immutable, and work with Jackson and Bean Validation out of the box. Never accept or return JPA entities from a controller: you leak your schema, invite over-posting (a client setting fields you did not intend), and tie your API to your table design.
Java
public record CreateUserRequest(
@NotBlank @Size(max = 100) String name,
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password) {}
public record UserResponse(Long id, String name, String email) {
static UserResponse from(UserEntity u) { return new UserResponse(u.getId(), u.getName(), u.getEmail()); }
}
@GetMapping("/search")
public PagedModel<UserResponse> search(
@RequestParam(required = false) String name, // ?name=ali
@RequestParam(defaultValue = "true") boolean active, // ?active=false
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate since, // ?since=2026-01-31
@RequestHeader(name = "X-Request-Id", required = false) String requestId,
@PageableDefault(size = 20, sort = "name") Pageable pageable) { // ?page=0&size=20&sort=name,asc
return new PagedModel<>(userService.search(name, active, since, pageable));
}ResponseEntity, Caching and Downloads
Return a plain object when the answer is always 200 with a body. Return ResponseEntity when you need to set headers or the status varies at runtime. For a fixed non-200 status, @ResponseStatus on the method is lighter.
Conditional requests are mostly handled for you. If you set an ETag (or Last-Modified) on a ResponseEntity for a GET, Spring compares it with the request's If-None-Match header and turns the response into 304 Not Modified when they match, so you do not need a custom header mapping for it. Note that the handler still runs and produces the body; the saving is bandwidth, not computation. For a cheap blanket solution, ShallowEtagHeaderFilter computes ETags from response bytes.
For file downloads, return a Resource, build Content-Disposition with ContentDisposition (it handles filename encoding for you), and use CacheControl rather than hand-written header strings. When you return a Resource, Spring also honours Range requests and replies with 206 Partial Content, so resumable downloads work without code.
Java
@RestController
@RequestMapping("/api/v1/documents")
public class DocumentController {
private final DocumentService docService;
public DocumentController(DocumentService docService) { this.docService = docService; }
// ETag: Spring returns 304 automatically when If-None-Match matches
@GetMapping("/{id}")
public ResponseEntity<DocumentResponse> findById(@PathVariable Long id) {
DocumentResponse doc = docService.findById(id);
return ResponseEntity.ok()
.eTag(String.valueOf(doc.version())) // quotes are added for you
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePrivate())
.body(doc);
}
// Download: Resource body, proper disposition, Range requests supported
@GetMapping("/{id}/export")
public ResponseEntity<Resource> export(@PathVariable Long id) {
Resource file = docService.export(id);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("doc-" + id + ".pdf").build().toString())
.cacheControl(CacheControl.noStore())
.body(file);
}
}Error Responses with ProblemDetail
Controllers should not catch exceptions and build error bodies by hand. Throw domain exceptions from the service and translate them in one place with @RestControllerAdvice. Spring 6 supports the standard RFC 9457 "problem detail" format through the ProblemDetail class, so every error has the same shape (type, title, status, detail, instance) and clients can parse them uniformly.
Extending ResponseEntityExceptionHandler gives you that format for Spring's own exceptions (malformed JSON, validation failures, unsupported media type, missing parameters) and lets you add your own handlers on top. Without extending it, you can switch the same behaviour on with spring.mvc.problemdetails.enabled=true.
Be careful not to leak internals in the detail text: stack traces, SQL, and class names belong in the logs, not in the response.
Java
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
ProblemDetail notFound(UserNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("User not found");
return pd;
}
@ExceptionHandler(EmailAlreadyUsedException.class)
ProblemDetail conflict(EmailAlreadyUsedException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "Email is already registered");
pd.setTitle("Conflict");
return pd;
}
}
// Response
// HTTP/1.1 404 Content-Type: application/problem+json
// { "type": "about:blank", "title": "User not found", "status": 404,
// "detail": "No user with id 42", "instance": "/api/v1/users/42" }Controller Design Rules
A few habits keep a controller layer easy to maintain as the API grows.
Keep business logic out of controllers. The classic example is a uniqueness check followed by a save in the controller: besides being the wrong layer, check-then-insert is a race (two requests can both pass the check), so rely on a unique constraint in the database and translate the violation into a 409 in the service.
Use one controller per resource, version the path (/api/v1), use constructor injection, and return DTOs instead of entities. Express failures with exceptions and a single handler, not with status codes scattered across controller methods.
Java
// Wrong: repository and rules in the controller; check-then-insert race
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody CreateUserRequest req) {
if (userRepository.existsByEmail(req.email())) throw new ConflictException("Email taken");
UserEntity user = userRepository.save(new UserEntity(req.name(), req.email()));
return ResponseEntity.status(201).body(UserResponse.from(user));
}
// Right: the controller maps HTTP; the service owns the rule and the constraint handling
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody @Valid CreateUserRequest req) {
UserResponse created = userService.create(req);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
// In the service
@Transactional
public UserResponse create(CreateUserRequest req) {
try {
return UserResponse.from(userRepository.saveAndFlush(UserEntity.from(req))); // unique index on email
} catch (DataIntegrityViolationException e) {
throw new EmailAlreadyUsedException(req.email()); // handler maps to 409
}
}