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
HttpStatusconstants (e.g.,HttpStatus.NOT_FOUND) over raw integers for readability