Framework Cookbooks

Spring Boot Error Handling with @ControllerAdvice

A complete guide to Spring Boot error handling using @ControllerAdvice, @ExceptionHandler, ResponseStatusException, and RFC 9457 Problem Details in Spring 6.

Spring's Default Error Handling

Out of the box, Spring Boot maps unhandled exceptions to /error via BasicErrorController. This returns an HTML page for browser requests and a JSON body for API clients. While convenient, it exposes Spring internals and lacks a consistent format.

The default JSON error body looks like:

{
  "timestamp": "2026-01-15T10:00:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/api/users/99"
}

Replace this with a structured approach using @ControllerAdvice.

@ControllerAdvice and @ExceptionHandler

@ControllerAdvice creates a cross-cutting concern class that intercepts exceptions from all controllers. Pair it with @ExceptionHandler to handle specific exception types:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(EntityNotFoundException ex) {
        return new ErrorResponse("not_found", ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
    public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .toList();
        return new ErrorResponse("validation_error", errors.toString());
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneric(Exception ex) {
        return new ErrorResponse("server_error", "An unexpected error occurred");
    }
}

The @RestControllerAdvice annotation combines @ControllerAdvice and @ResponseBody, so return values are automatically serialized to JSON.

ResponseStatusException

For simple, inline error responses without a dedicated exception class, throw ResponseStatusException:

@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) {
    return userRepository.findById(id)
        .map(UserDto::from)
        .orElseThrow(() -> new ResponseStatusException(
            HttpStatus.NOT_FOUND,
            "User with id " + id + " not found"
        ));
}

For domain-level errors, annotate your custom exception class:

@ResponseStatus(HttpStatus.CONFLICT)
public class DuplicateEmailException extends RuntimeException {
    public DuplicateEmailException(String email) {
        super("Email already registered: " + email);
    }
}

Custom Error Response DTOs

Define a standardized error DTO to ensure consistent API responses:

public record ErrorResponse(
    String code,
    String message,
    int status,
    String path,
    Instant timestamp
) {
    public static ErrorResponse of(
        String code,
        String message,
        HttpStatus status,
        HttpServletRequest request
    ) {
        return new ErrorResponse(
            code,
            message,
            status.value(),
            request.getRequestURI(),
            Instant.now()
        );
    }
}

Problem Details (RFC 9457) with Spring 6

Spring 6 (Boot 3.x) has native RFC 9457 support via ProblemDetail:

@RestControllerAdvice
public class ProblemDetailsHandler {

    @ExceptionHandler(EntityNotFoundException.class)
    public ProblemDetail handleNotFound(EntityNotFoundException ex,
                                        HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage()
        );
        problem.setTitle("Resource Not Found");
        problem.setType(URI.create("https://example.com/errors/not-found"));
        problem.setInstance(URI.create(request.getRequestURI()));
        return problem;
    }
}

Enable Problem Details support in application.yml:

spring:
  mvc:
    problemdetails:
      enabled: true

Validation Errors (422 vs 400)

Spring defaults to 400 Bad Request for @Valid failures. Override this to return 422 Unprocessable Entity with field-level details:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(
    MethodArgumentNotValidException ex
) {
    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
    problem.setTitle("Validation Failed");

    Map<String, String> fieldErrors = ex.getBindingResult()
        .getFieldErrors()
        .stream()
        .collect(Collectors.toMap(
            FieldError::getField,
            FieldError::getDefaultMessage,
            (a, b) -> a  // Keep first error per field
        ));

    problem.setProperty("errors", fieldErrors);
    return ResponseEntity.unprocessableEntity().body(problem);
}

Logging Errors with SLF4J and Correlation IDs

Log errors with structured context so you can trace a specific request through distributed logs. Spring's @ControllerAdvice has access to the HttpServletRequest, which may carry a correlation ID header:

@RestControllerAdvice
@Slf4j
public class LoggingExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleAll(
        Exception ex, HttpServletRequest request
    ) {
        String requestId = request.getHeader("X-Request-Id");

        // 5xx: log at ERROR level; 4xx: log at WARN level
        if (ex instanceof ResponseStatusException rse && rse.getStatusCode().is4xxClientError()) {
            log.warn("Client error: {} {} requestId={}",
                rse.getStatusCode(), rse.getReason(), requestId);
        } else {
            log.error("Server error: requestId={}", requestId, ex);
        }

        int status = ex instanceof ResponseStatusException rse
            ? rse.getStatusCode().value()
            : 500;

        ProblemDetail problem = ProblemDetail.forStatus(status);
        problem.setProperty("requestId", requestId);
        return ResponseEntity.status(status).body(problem);
    }
}

Key principles for Spring error handling:

  • Always declare @RestControllerAdvice (not @ControllerAdvice) for JSON APIs
  • List more specific exception handlers before generic ones in the same @ControllerAdvice
  • Never expose stack traces in production responses — log them server-side instead
  • Use HttpStatus constants (e.g., HttpStatus.NOT_FOUND) over raw integers for readability

Verwandte Protokolle

Verwandte Glossarbegriffe

Mehr in Framework Cookbooks