Spring Boot
REST Controller Basics
A REST controller in Spring Boot is a class annotated with @RestController that handles HTTP requests and returns serialized response bodies. This entry covers the anatomy of a controller, request mapping, HTTP method annotations, return types, and the difference between @Controller and @RestController.
Anatomy of a REST Controller
A REST controller is thin — it handles HTTP concerns only and delegates all logic to a service. The class-level annotations establish the base path; method-level annotations map each operation to an HTTP verb and URI template. Fields are injected via constructor injection.
Java
@RestController // (1) marks class as REST controller
@RequestMapping("/api/v1/users") // (2) base path for all methods
@RequiredArgsConstructor // (3) Lombok constructor injection
@Validated // (4) enables parameter-level validation
@Slf4j // (5) injects logger
public class UserController {
private final UserService userService; // (6) injected service
@GetMapping // GET /api/v1/users
public ResponseEntity<Page<UserResponse>> findAll(
@PageableDefault(size = 20) Pageable pageable) {
return ResponseEntity.ok(userService.findAll(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();
}
}@Controller vs @RestController
@Controller is the base MVC annotation — handler methods return view names resolved to templates by a ViewResolver. @RestController combines @Controller and @ResponseBody, making every method serialize its return value directly to the HTTP response body. Use @RestController for all REST API endpoints.
Java
// ── @Controller — view-based ──────────────────────────────────────────
@Controller
public class PageController {
@GetMapping("/users")
public String usersPage(Model model) {
model.addAttribute("users", userService.findAll());
return "users"; // resolved to users.html by ViewResolver
}
// Mix-in: one method returns JSON
@GetMapping("/api/users")
@ResponseBody // this method only — serialize to JSON
public List<UserResponse> usersJson() {
return userService.findAll();
}
}
// ── @RestController — every method returns serialized body ────────────
@RestController // = @Controller + @ResponseBody on every method
@RequestMapping("/api/v1/users")
public class UserController {
@GetMapping
public List<UserResponse> findAll() {
return userService.findAll(); // no @ResponseBody needed
}
}
// ── When to use which ─────────────────────────────────────────────────
// @RestController → REST API endpoints (JSON / XML)
// @Controller → Server-side rendering (Thymeleaf, Freemarker)
// @Controller + select @ResponseBody → rare mixed MVC + API controllerHTTP Method Annotations
Spring provides composed mapping annotations for each HTTP verb. Each is a shortcut for @RequestMapping(method = RequestMethod.X). Use the composed annotations — they are more readable and communicate intent at a glance.
Java
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
// ── GET — retrieve resource(s) ────────────────────────────────────
@GetMapping // collection
public List<OrderResponse> findAll() { ... }
@GetMapping("/{id}") // single resource
public OrderResponse findById(@PathVariable Long id) { ... }
// ── POST — create a new resource ──────────────────────────────────
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody @Valid CreateOrderRequest req,
UriComponentsBuilder ucb) {
OrderResponse created = orderService.create(req);
return ResponseEntity.created(
ucb.path("/api/v1/orders/{id}")
.buildAndExpand(created.id()).toUri())
.body(created);
}
// ── PUT — full replacement ─────────────────────────────────────────
@PutMapping("/{id}")
public OrderResponse replace(
@PathVariable Long id,
@RequestBody @Valid ReplaceOrderRequest req) {
return orderService.replace(id, req);
}
// ── PATCH — partial update ─────────────────────────────────────────
@PatchMapping("/{id}")
public OrderResponse patch(
@PathVariable Long id,
@RequestBody @Valid PatchOrderRequest req) {
return orderService.patch(id, req);
}
// ── DELETE — remove resource ───────────────────────────────────────
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
orderService.delete(id);
return ResponseEntity.noContent().build();
}
// ── HEAD — same as GET but no body (check existence / headers) ────
@RequestMapping(value = "/{id}", method = RequestMethod.HEAD)
public ResponseEntity<Void> exists(@PathVariable Long id) {
orderService.findById(id); // throws 404 if absent
return ResponseEntity.ok().build();
}
// ── OPTIONS — advertise allowed methods ───────────────────────────
@RequestMapping(value = "/{id}", method = RequestMethod.OPTIONS)
public ResponseEntity<Void> options() {
return ResponseEntity.ok()
.allow(HttpMethod.GET, HttpMethod.PUT,
HttpMethod.PATCH, HttpMethod.DELETE)
.build();
}
}ResponseEntity
ResponseEntity<T> gives full control over the HTTP response — status code, headers, and body. Return it whenever you need to set a non-200 status, add response headers, or return an empty body. For simple 200 responses where no headers are needed, returning T directly is acceptable.
Java
@RestController
@RequestMapping("/api/v1/documents")
public class DocumentController {
// ── 200 OK with body ──────────────────────────────────────────────
@GetMapping("/{id}")
public ResponseEntity<DocumentResponse> findById(@PathVariable Long id) {
return ResponseEntity.ok(docService.findById(id));
}
// ── 201 Created with Location header ─────────────────────────────
@PostMapping
public ResponseEntity<DocumentResponse> create(
@RequestBody @Valid CreateDocumentRequest req,
UriComponentsBuilder ucb) {
DocumentResponse doc = docService.create(req);
return ResponseEntity.created(
ucb.path("/api/v1/documents/{id}")
.buildAndExpand(doc.id()).toUri())
.body(doc);
}
// ── 204 No Content ────────────────────────────────────────────────
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
docService.delete(id);
return ResponseEntity.noContent().build();
}
// ── 206 Partial Content ───────────────────────────────────────────
@GetMapping("/{id}/content")
public ResponseEntity<byte[]> content(
@PathVariable Long id,
@RequestHeader(value = HttpHeaders.RANGE,
required = false) String range) {
return docService.getContent(id, range);
}
// ── Custom headers ────────────────────────────────────────────────
@GetMapping("/{id}/export")
public ResponseEntity<Resource> export(@PathVariable Long id) {
Resource file = docService.export(id);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename="doc-" + id + ".pdf"")
.header(HttpHeaders.CACHE_CONTROL,
"no-cache, no-store, must-revalidate")
.contentType(MediaType.APPLICATION_PDF)
.body(file);
}
// ── Conditional response (ETag / Last-Modified) ───────────────────
@GetMapping(value = "/{id}", headers = "If-None-Match")
public ResponseEntity<DocumentResponse> findByIdCached(
@PathVariable Long id,
@RequestHeader("If-None-Match") String etag) {
DocumentResponse doc = docService.findById(id);
String currentEtag = """ + doc.version() + """;
if (currentEtag.equals(etag)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
return ResponseEntity.ok()
.eTag(currentEtag)
.body(doc);
}
}Controller Design Rules
A well-designed REST controller is thin, stateless, and focused on HTTP concerns only. These rules keep controllers maintainable as the API grows.
Java
// ── Rule 1: No business logic in controllers ─────────────────────────
// WRONG — repository and logic directly in controller:
@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody CreateUserRequest req) {
if (userRepository.existsByEmail(req.email())) {
throw new ConflictException("Email taken");
}
User user = new User(req.name(), req.email());
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 ucb) {
UserResponse created = userService.create(req);
return ResponseEntity.created(
ucb.path("/api/v1/users/{id}")
.buildAndExpand(created.id()).toUri())
.body(created);
}
// ── Rule 2: One controller per resource ───────────────────────────────
// UserController → /users
// OrderController → /orders
// ProductController → /products
// ── Rule 3: Never expose JPA entities directly ────────────────────────
// WRONG: public User findById(...) → exposes internals
// CORRECT: public UserResponse findById(...)→ returns DTO
// ── Rule 4: Constructor injection, not field injection ────────────────
// WRONG: @Autowired private UserService userService;
// CORRECT: private final UserService userService; + @RequiredArgsConstructor
// ── Rule 5: Version in the base path ──────────────────────────────────
@RequestMapping("/api/v1/users")
// ── Rule 6: Explicit ResponseEntity for non-200 responses ─────────────
// Return ResponseEntity<T> whenever the status code is not 200 OK
// so it is visible and reviewable at the method signature level.