Spring Boot
REST Controller
@RestController is the central annotation for building REST APIs in Spring Boot. It combines @Controller and @ResponseBody, making every handler method serialize its return value directly to the HTTP response body. This entry covers the full anatomy of a REST controller — from annotation setup through request mapping, parameter binding, response shaping, and exception handling.
@RestController vs @Controller
@Controller is the base annotation for Spring MVC controllers. By default it returns view names — strings resolved to templates by a ViewResolver. @ResponseBody on a method (or @RestController on the class) switches the return value to be serialized directly into the HTTP response body via an HttpMessageConverter.
@RestController is a composed annotation that combines @Controller and @ResponseBody. It is the correct choice for any controller that serves JSON or XML to HTTP clients rather than rendering HTML templates.
Java
// ── @Controller — view-based (not for REST APIs) ─────────────────────
@Controller
public class PageController {
@GetMapping("/users")
public String usersPage(Model model) {
model.addAttribute("users", userService.findAll());
return "users"; // resolved to a template: users.html
}
@GetMapping("/api/users")
@ResponseBody // this one method returns JSON, not a view name
public List<UserResponse> usersJson() {
return userService.findAll();
}
}
// ── @RestController — every method returns serialized body ────────────
@RestController // = @Controller + @ResponseBody on every method
@RequestMapping("/users")
public class UserController {
@GetMapping
public List<UserResponse> findAll() {
return userService.findAll(); // serialized to JSON — no @ResponseBody needed
}
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
}
}
// ── When to use which ─────────────────────────────────────────────────
// @RestController — REST API endpoints returning JSON/XML
// @Controller — Server-side rendering (Thymeleaf, Freemarker, JSP)
// @Controller + @ResponseBody on select methods — mixed MVC + API controller
// (uncommon; prefer separate controllers)Anatomy of a REST Controller
A production REST controller is thin — it handles HTTP concerns only and delegates all logic to a service. The class-level annotations establish the base path and JSON content type; method-level annotations map each operation to an HTTP verb and URI.
Java
@RestController // (1) mark as REST controller
@RequestMapping("/api/v1/users") // (2) base path for all methods
@RequiredArgsConstructor // (3) constructor injection via Lombok
@Validated // (4) enable parameter-level Bean Validation
public class UserController {
private final UserService userService; // (5) injected service — no business logic here
// (6) Handler methods — one per operation:
@GetMapping // GET /api/v1/users
public ResponseEntity<Page<UserResponse>> findAll(
@PageableDefault(size = 20) Pageable pageable,
@RequestParam(required = false) String search) {
return ResponseEntity.ok(userService.findAll(search, pageable));
}
@GetMapping("/{id}") // GET /api/v1/users/{id}
public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping // POST /api/v1/users
public ResponseEntity<UserResponse> create(
@RequestBody @Valid CreateUserRequest request,
UriComponentsBuilder uriBuilder) {
UserResponse created = userService.create(request);
URI location = uriBuilder.path("/api/v1/users/{id}")
.buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}") // PUT /api/v1/users/{id}
public ResponseEntity<UserResponse> update(
@PathVariable Long id,
@RequestBody @Valid UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@PatchMapping("/{id}") // PATCH /api/v1/users/{id}
public ResponseEntity<UserResponse> patch(
@PathVariable Long id,
@RequestBody @Valid PatchUserRequest request) {
return ResponseEntity.ok(userService.patch(id, request));
}
@DeleteMapping("/{id}") // DELETE /api/v1/users/{id}
public ResponseEntity<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}Content Negotiation and Media Types
By default @RestController serializes responses to JSON using Jackson. Adding the Jackson XML extension enables XML serialization without changing controller code — Spring selects the serializer based on the client's Accept header. The produces and consumes attributes on mapping annotations narrow which media types a handler accepts.
XML
<!-- Enable XML support alongside JSON — add to pom.xml: -->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
// ── Controller — handles both JSON and XML automatically ───────────────
@RestController
@RequestMapping(value = "/users",
produces = {MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE})
public class UserController {
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
// GET /users/42, Accept: application/json → JSON response
// GET /users/42, Accept: application/xml → XML response
}
}
// ── Restrict a single method to one media type ────────────────────────
@GetMapping(value = "/{id}/card",
produces = MediaType.TEXT_HTML_VALUE)
public String userCard(@PathVariable Long id, Model model) {
// Returns an HTML fragment — only this method, not the whole controller
return "<div>" + userService.findById(id).name() + "</div>";
}
// ── Restrict request body format ──────────────────────────────────────
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<UserResponse> create(
@RequestBody @Valid CreateUserRequest request) {
return ResponseEntity.status(201).body(userService.create(request));
}
// ── Custom media type ─────────────────────────────────────────────────
@GetMapping(value = "/{id}",
produces = "application/vnd.myapp.user.v2+json")
public UserResponseV2 findByIdV2(@PathVariable Long id) {
return userService.findByIdV2(id);
}Exception Handling in REST Controllers
Exception handling belongs in a @RestControllerAdvice class, not in individual controllers. This centralizes error response formatting, keeps controllers clean, and ensures every exception maps to a consistent error body structure.
Java
// ── Standardised error response ──────────────────────────────────────
public record ErrorResponse(
int status,
String error,
String message,
Instant timestamp,
Map<String, String> fieldErrors
) {
public static ErrorResponse of(int status, String error, String message) {
return new ErrorResponse(status, error, message, Instant.now(), Map.of());
}
}
// ── Global exception handler ───────────────────────────────────────────
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(404, "Not Found", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid",
(a, b) -> a));
return ResponseEntity.badRequest().body(
new ErrorResponse(400, "Validation Failed",
"One or more fields failed validation", Instant.now(), errors));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleUnreadable(
HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.body(ErrorResponse.of(400, "Bad Request", "Malformed request body"));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(
ConstraintViolationException ex) {
Map<String, String> errors = ex.getConstraintViolations().stream()
.collect(Collectors.toMap(
cv -> cv.getPropertyPath().toString(),
ConstraintViolation::getMessage));
return ResponseEntity.badRequest().body(
new ErrorResponse(400, "Validation Failed",
"Parameter validation failed", Instant.now(), errors));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of(500, "Internal Server Error",
"An unexpected error occurred"));
}
}Controller Design Best Practices
A well-designed REST controller is thin, stateless, and focused on HTTP concerns only. These patterns keep controllers maintainable as the API grows.
Java
// ── 1. Keep controllers thin — no business logic ──────────────────────
// WRONG — business logic in the controller:
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody @Valid CreateUserRequest req) {
if (userRepository.existsByEmail(req.email())) {
throw new ConflictException("Email taken");
}
User user = new User();
user.setEmail(req.email());
user.setName(req.name());
user.setCreatedAt(LocalDateTime.now());
userRepository.save(user);
return ResponseEntity.status(201).body(UserResponse.from(user));
}
// CORRECT — delegate everything to the service:
@PostMapping
public ResponseEntity<UserResponse> create(
@RequestBody @Valid CreateUserRequest req,
UriComponentsBuilder uriBuilder) {
UserResponse created = userService.create(req);
URI location = uriBuilder.path("/users/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
// ── 2. One controller per resource ────────────────────────────────────
// UserController → /users
// OrderController → /orders
// ProductController → /products
// Avoid mega-controllers that handle multiple unrelated resources.
// ── 3. Never expose JPA entities directly ─────────────────────────────
// WRONG:
@GetMapping("/{id}")
public User findById(@PathVariable Long id) { // exposes entity — lazy load issues,
return userRepository.findById(id).orElseThrow(); // no field control
}
// CORRECT: return a DTO (UserResponse record)
// ── 4. Use constructor injection, not field injection ─────────────────
// WRONG:
@Autowired private UserService userService; // harder to test, hides dependencies
// CORRECT (with Lombok @RequiredArgsConstructor):
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService; // immutable, explicit dependency
}
// ── 5. Version your API in the base path ─────────────────────────────
@RequestMapping("/api/v1/users") // clear, stable, cache-friendly versioning
// ── 6. Return ResponseEntity for full HTTP control ────────────────────
// Prefer ResponseEntity<T> over plain T return types in REST controllers
// so status codes and headers are always explicit and reviewable.A Complete REST Controller
A full, production-ready controller for a User resource — showing all CRUD operations, pagination, search, proper status codes, and Location headers in one coherent example.
Java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserResponse>> findAll(
@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC) Pageable pageable,
@RequestParam(required = false)
@Size(max = 100) String search) {
log.debug("GET /users search={} pageable={}", search, pageable);
return ResponseEntity.ok(userService.findAll(search, pageable));
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> findById(
@PathVariable @Positive Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity<UserResponse> create(
@RequestBody @Valid CreateUserRequest request,
UriComponentsBuilder uriBuilder) {
UserResponse created = userService.create(request);
URI location = uriBuilder
.path("/api/v1/users/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> update(
@PathVariable @Positive Long id,
@RequestBody @Valid UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@PatchMapping("/{id}")
public ResponseEntity<UserResponse> patch(
@PathVariable @Positive Long id,
@RequestBody @Valid PatchUserRequest request) {
return ResponseEntity.ok(userService.patch(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(
@PathVariable @Positive Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/orders")
public ResponseEntity<Page<OrderResponse>> findOrders(
@PathVariable @Positive Long id,
@PageableDefault(size = 20) Pageable pageable) {
return ResponseEntity.ok(orderService.findByUser(id, pageable));
}
}