Error handling for Spring Boot APIs — @ControllerAdvice, structured error
84
75%
Does it follow best practices?
Impact
99%
1.76xAverage score across 5 eval scenarios
Passed
No known issues
Production-grade error handling that belongs in every Spring Boot REST API from day one. When building any Spring Boot application -- whether a quick prototype or a production service -- always include these patterns without being asked.
Error handling is not a "harden later" task. Without it from the start:
{ "error": "..." }, another returns { "message": "..." }, a third returns Spring's default Whitelabel error page. Clients cannot parse errors reliably.MethodArgumentNotValidException, Spring returns a 400 with a massive JSON blob that clients cannot meaningfully display.DataIntegrityViolationException exposes table names, column names, and constraint names in its raw message.RIGHT -- Define typed exceptions with HTTP status mapping:
// src/main/java/com/example/exception/AppException.java
public abstract class AppException extends RuntimeException {
private final HttpStatus status;
private final String code;
protected AppException(HttpStatus status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
public HttpStatus getStatus() { return status; }
public String getCode() { return code; }
}public class ResourceNotFoundException extends AppException {
public ResourceNotFoundException(String resource, Object id) {
super(HttpStatus.NOT_FOUND, "NOT_FOUND", resource + " with id " + id + " not found");
}
}
public class BusinessRuleException extends AppException {
public BusinessRuleException(String message) {
super(HttpStatus.UNPROCESSABLE_ENTITY, "BUSINESS_RULE_VIOLATION", message);
}
}
public class DuplicateResourceException extends AppException {
public DuplicateResourceException(String message) {
super(HttpStatus.CONFLICT, "DUPLICATE", message);
}
}WRONG -- Throwing generic RuntimeException or using ResponseStatusException without a consistent error format:
// BAD: Inconsistent, no structured body, different format per endpoint
throw new RuntimeException("Order not found");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Order not found");RIGHT -- A single centralized handler that catches everything and returns a consistent format:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// Handle custom application exceptions
@ExceptionHandler(AppException.class)
public ResponseEntity<Map<String, Object>> handleAppException(AppException ex, HttpServletRequest request) {
log.warn("Application error: {} {} -> {} {}", request.getMethod(), request.getRequestURI(), ex.getCode(), ex.getMessage());
return ResponseEntity.status(ex.getStatus())
.body(Map.of("error", Map.of(
"code", ex.getCode(),
"message", ex.getMessage()
)));
}
// Handle @Valid validation failures
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
List<Map<String, String>> details = ex.getBindingResult().getFieldErrors().stream()
.map(e -> Map.of("field", e.getField(), "message", Objects.requireNonNullElse(e.getDefaultMessage(), "invalid")))
.toList();
return ResponseEntity.badRequest()
.body(Map.of("error", Map.of(
"code", "VALIDATION_ERROR",
"message", "Invalid request",
"details", details
)));
}
// Handle type mismatches in path variables / request params
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Map<String, Object>> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
String message = "Parameter '" + ex.getName() + "' must be of type " + ex.getRequiredType().getSimpleName();
return ResponseEntity.badRequest()
.body(Map.of("error", Map.of("code", "INVALID_PARAMETER", "message", message)));
}
// Handle malformed JSON
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, Object>> handleUnreadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.body(Map.of("error", Map.of("code", "MALFORMED_REQUEST", "message", "Request body is malformed or missing")));
}
// Handle database constraint violations (e.g., unique constraints)
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<Map<String, Object>> handleDataIntegrity(DataIntegrityViolationException ex) {
log.error("Data integrity violation", ex);
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", Map.of("code", "CONFLICT", "message", "Operation violates a data constraint")));
}
// Catch-all for unexpected exceptions -- never leak internals
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception at {} {}", request.getMethod(), request.getRequestURI(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", Map.of("code", "INTERNAL_ERROR", "message", "An unexpected error occurred")));
}
}WRONG -- Per-controller try/catch blocks that duplicate error handling logic:
// BAD: Error handling scattered across every controller method
@GetMapping("/api/orders/{id}")
public ResponseEntity<?> getOrder(@PathVariable Long id) {
try {
Order order = orderService.findById(id);
return ResponseEntity.ok(order);
} catch (OrderNotFoundException e) {
return ResponseEntity.status(404).body(Map.of("message", e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage())); // leaks internals
}
}RIGHT -- Configure Spring to never include stack traces or internal details in error responses:
# application.properties
server.error.include-stacktrace=never
server.error.include-message=never
server.error.include-binding-errors=never
server.error.include-exception=falseWRONG -- Leaving defaults or explicitly enabling stack traces:
# BAD: This leaks stack traces to clients
server.error.include-stacktrace=always
server.error.include-message=alwaysRIGHT -- Use Jakarta Validation annotations on DTOs with the Valid annotation on controller parameters:
public record CreateOrderRequest(
@NotBlank(message = "Customer name is required")
String customerName,
@NotEmpty(message = "Order must have at least one item")
@Valid
List<OrderItemRequest> items
) {}
public record OrderItemRequest(
@NotNull(message = "Product ID is required")
@Positive(message = "Product ID must be positive")
Long productId,
@Min(value = 1, message = "Quantity must be at least 1")
@Max(value = 100, message = "Quantity cannot exceed 100")
int quantity
) {}@PostMapping("/api/orders")
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderService.createOrder(request);
}WRONG -- Manual validation in controllers instead of using the annotation framework:
// BAD: Reimplementing what @Valid + annotations already do
@PostMapping("/api/orders")
public ResponseEntity<?> createOrder(@RequestBody CreateOrderRequest request) {
if (request.getCustomerName() == null || request.getCustomerName().isBlank()) {
return ResponseEntity.badRequest().body("Customer name is required");
}
// ... more manual checks
}RIGHT -- Add request context via a filter so all log lines for a request are traceable:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("requestId", requestId);
response.setHeader("X-Request-Id", requestId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.clear();
}
}
}And reference it in your log pattern:
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%X{requestId}] %-5level %logger{36} - %msg%nWRONG -- Logging without any request correlation:
// BAD: No way to correlate this log entry to the request that caused it
log.error("Failed to process order", ex);RIGHT -- Controllers throw exceptions and the global handler deals with responses:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Order", id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderService.createOrder(request);
}
}All errors must follow this shape:
{
"error": {
"code": "NOT_FOUND",
"message": "Order with id 42 not found"
}
}For validation errors with field-level detail:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request",
"details": [
{ "field": "customerName", "message": "Customer name is required" },
{ "field": "items", "message": "Order must have at least one item" }
]
}
}Every Spring Boot REST API must have from the start: