Spring REST Core Knowledge
Controller Basics
@RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<List<UserDto>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
List<UserDto> users = userService.findAll(page, size);
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserDto dto) {
UserDto created = userService.create(dto);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserDto> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserDto dto) {
return userService.update(id, dto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
ResponseEntity Patterns
// 200 OK with body return ResponseEntity.ok(data); return ResponseEntity.ok().body(data);
// 201 Created with Location header URI location = ServletUriComponentsBuilder .fromCurrentRequest() .path("/{id}") .buildAndExpand(id) .toUri(); return ResponseEntity.created(location).body(data);
// 204 No Content return ResponseEntity.noContent().build();
// 400 Bad Request return ResponseEntity.badRequest().body(error);
// 404 Not Found return ResponseEntity.notFound().build();
// Custom status return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
// With headers return ResponseEntity.ok() .header("X-Custom-Header", "value") .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)) .body(data);
Global Exception Handling
@RestControllerAdvice @Slf4j public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
log.warn("Resource not found: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.code("NOT_FOUND")
.message(ex.getMessage())
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
List<FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(err -> new FieldError(err.getField(), err.getDefaultMessage()))
.toList();
ErrorResponse error = ErrorResponse.builder()
.code("VALIDATION_ERROR")
.message("Validation failed")
.errors(fieldErrors)
.timestamp(Instant.now())
.build();
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
ErrorResponse error = ErrorResponse.builder()
.code("CONFLICT")
.message("Data integrity violation")
.timestamp(Instant.now())
.build();
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
ErrorResponse error = ErrorResponse.builder()
.code("INTERNAL_ERROR")
.message("An unexpected error occurred")
.timestamp(Instant.now())
.build();
return ResponseEntity.internalServerError().body(error);
}
}
@Data @Builder public class ErrorResponse { private String code; private String message; private List<FieldError> errors; private Instant timestamp; }
Validation
// DTO with validation @Data public class CreateUserDto { @NotBlank(message = "Name is required") @Size(min = 2, max = 100, message = "Name must be 2-100 characters") private String name;
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotNull(message = "Role is required")
private Role role;
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$", message = "Invalid phone number")
private String phone;
}
// Controller with validation @PostMapping public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserDto dto) { // Validation happens automatically return ResponseEntity.ok(userService.create(dto)); }
// Validation groups public interface OnCreate {} public interface OnUpdate {}
@Data public class UserDto { @Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) private Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
private String name;
}
@PostMapping public ResponseEntity<UserDto> create( @Validated(OnCreate.class) @RequestBody UserDto dto) { return ResponseEntity.ok(userService.create(dto)); }
HATEOAS
// Add dependency // spring-boot-starter-hateoas
@RestController @RequestMapping("/api/users") public class UserController {
@GetMapping("/{id}")
public EntityModel<UserDto> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(id)).withSelfRel(),
linkTo(methodOn(UserController.class).getUsers(0, 10)).withRel("users"),
linkTo(methodOn(OrderController.class).getUserOrders(id)).withRel("orders")
);
}
@GetMapping
public CollectionModel<EntityModel<UserDto>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
List<EntityModel<UserDto>> users = userService.findAll(page, size)
.stream()
.map(user -> EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(user.getId())).withSelfRel()))
.toList();
return CollectionModel.of(users,
linkTo(methodOn(UserController.class).getUsers(page, size)).withSelfRel());
}
}
RepresentationModelAssembler
@Component public class UserModelAssembler implements RepresentationModelAssembler<UserDto, EntityModel<UserDto>> {
@Override
public EntityModel<UserDto> toModel(UserDto user) {
return EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(user.getId())).withSelfRel(),
linkTo(methodOn(UserController.class).getUsers(0, 10)).withRel("users"));
}
}
// Usage in controller @RestController @RequiredArgsConstructor public class UserController {
private final UserModelAssembler assembler;
@GetMapping("/{id}")
public EntityModel<UserDto> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id).orElseThrow();
return assembler.toModel(user);
}
}
Content Negotiation
// Controller supporting multiple formats @GetMapping(value = "/{id}", produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE }) public ResponseEntity<UserDto> getUser(@PathVariable Long id) { return ResponseEntity.ok(userService.findById(id).orElseThrow()); }
// Configuration @Configuration public class WebConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.favorParameter(true)
.parameterName("format")
.defaultContentType(MediaType.APPLICATION_JSON)
.mediaType("json", MediaType.APPLICATION_JSON)
.mediaType("xml", MediaType.APPLICATION_XML);
}
}
API Versioning
URL Versioning
@RestController @RequestMapping("/api/v1/users") public class UserControllerV1 { }
@RestController @RequestMapping("/api/v2/users") public class UserControllerV2 { }
Header Versioning
@GetMapping(value = "/users", headers = "X-API-Version=1") public List<UserV1Dto> getUsersV1() { }
@GetMapping(value = "/users", headers = "X-API-Version=2") public List<UserV2Dto> getUsersV2() { }
Media Type Versioning
@GetMapping(value = "/users", produces = "application/vnd.company.v1+json") public List<UserV1Dto> getUsersV1() { }
@GetMapping(value = "/users", produces = "application/vnd.company.v2+json") public List<UserV2Dto> getUsersV2() { }
Async Controllers
@GetMapping("/async/{id}") public CompletableFuture<ResponseEntity<UserDto>> getAsync(@PathVariable Long id) { return userService.findByIdAsync(id) .thenApply(user -> user .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build())); }
@GetMapping("/deferred/{id}") public DeferredResult<ResponseEntity<UserDto>> getDeferred(@PathVariable Long id) { DeferredResult<ResponseEntity<UserDto>> result = new DeferredResult<>();
userService.findByIdAsync(id)
.thenAccept(user -> result.setResult(
user.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build())));
return result;
}
@GetMapping("/callable/{id}") public Callable<ResponseEntity<UserDto>> getCallable(@PathVariable Long id) { return () -> userService.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); }
Streaming Response
// Server-Sent Events @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> streamEvents() { return Flux.interval(Duration.ofSeconds(1)) .map(seq -> ServerSentEvent.<String>builder() .id(String.valueOf(seq)) .event("message") .data("Event " + seq) .build()); }
// Large file download @GetMapping("/download/{id}") public ResponseEntity<StreamingResponseBody> downloadFile(@PathVariable Long id) { return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=file.csv") .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(outputStream -> { // Write data to outputStream fileService.writeToStream(id, outputStream); }); }
Production Readiness
Request/Response Logging
@Component public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
long start = System.currentTimeMillis();
String requestId = UUID.randomUUID().toString();
MDC.put("requestId", requestId);
try {
filterChain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - start;
log.info("{} {} {} {}ms",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
duration);
MDC.clear();
}
}
}
Checklist
-
ResponseEntity for all responses
-
Global exception handler
-
Input validation with @Valid
-
Proper HTTP status codes
-
Location header for 201 Created
-
HATEOAS links where appropriate
-
Content negotiation configured
-
API versioning strategy
-
Request logging with timing
-
Request ID tracking
When NOT to Use This Skill
-
GraphQL APIs - Use spring-graphql skill
-
Reactive/WebFlux - Use spring-webflux skill
-
HATEOAS deep dive - Use spring-hateoas skill
-
gRPC - Use gRPC-specific patterns
Anti-Patterns
Anti-Pattern Problem Solution
Returning entities Tight coupling Use DTOs
No exception handler Inconsistent errors Add @ControllerAdvice
Missing @Valid No input validation Add @Valid annotation
Wrong HTTP status Confuses clients Use proper status codes
Missing Location header 201 incomplete Add Location for POST
Quick Troubleshooting
Problem Diagnostic Fix
415 Unsupported Media Check Content-Type Add produces/consumes
Validation not working Check @Valid Add @Valid to @RequestBody
Exception not caught Check advice Verify @RestControllerAdvice
Wrong JSON field names Check serialization Configure Jackson
CORS errors Check CORS config Add @CrossOrigin or config
Reference Documentation
- Spring MVC Reference