Spring Boot
Global Exception Handling
@RestControllerAdvice is the central place for all exception handling in a Spring Boot REST API. It intercepts exceptions thrown by any @RestController, maps them to HTTP responses, and keeps error-handling logic out of individual controllers. This entry covers the full @RestControllerAdvice setup, handler ordering, logging strategy, security exception integration, and async exception handling.
@RestControllerAdvice Setup
@RestControllerAdvice is a composed annotation combining @ControllerAdvice and @ResponseBody. Every @ExceptionHandler method inside it applies to all @RestController classes in the application. Declare one primary advice class for domain exceptions and a second for infrastructure or Spring MVC exceptions to keep responsibilities separate.
Java
// ── Primary advice — domain exceptions ───────────────────────────────
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// ── 404 Not Found ──────────────────────────────────────────────────
@ExceptionHandler({
EntityNotFoundException.class,
ResourceNotFoundException.class
})
public ResponseEntity<ErrorResponse> handleNotFound(
RuntimeException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(404, "Not Found",
ex.getMessage(), req.getRequestURI()));
}
// ── 409 Conflict ──────────────────────────────────────────────────
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleConflict(
DuplicateResourceException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ErrorResponse.of(409, "Conflict",
ex.getMessage(), req.getRequestURI()));
}
// ── 422 Unprocessable Entity ──────────────────────────────────────
@ExceptionHandler(BusinessRuleException.class)
public ResponseEntity<ErrorResponse> handleBusinessRule(
BusinessRuleException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(ErrorResponse.of(422, "Unprocessable Entity",
ex.getMessage(), req.getRequestURI()));
}
// ── 400 Validation ────────────────────────────────────────────────
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest req) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null
? fe.getDefaultMessage() : "Invalid",
(a, b) -> a));
return ResponseEntity.badRequest()
.body(ErrorResponse.ofFields(400, "Validation Failed",
"One or more fields failed validation",
req.getRequestURI(), fieldErrors));
}
// ── 500 Catch-all ─────────────────────────────────────────────────
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(
Exception ex, HttpServletRequest req) {
log.error("Unhandled exception: {} {}",
req.getMethod(), req.getRequestURI(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of(500, "Internal Server Error",
"An unexpected error occurred", req.getRequestURI()));
}
}Scoping Advice to Specific Controllers
By default @RestControllerAdvice applies to every controller. Narrow its scope with basePackages, basePackageClasses, or assignableTypes to create version-specific or module-specific advice classes without them interfering with each other.
Java
// ── Scoped to a package ───────────────────────────────────────────────
@RestControllerAdvice(basePackages = "com.myapp.api.v1")
public class V1ExceptionHandler {
@ExceptionHandler(LegacyApiException.class)
public ResponseEntity<LegacyErrorResponse> handleLegacy(
LegacyApiException ex) {
// V1 uses a different error shape
return ResponseEntity.badRequest()
.body(new LegacyErrorResponse(ex.getCode(), ex.getMessage()));
}
}
// ── Scoped to specific controller classes ─────────────────────────────
@RestControllerAdvice(assignableTypes = {
PaymentController.class,
RefundController.class
})
public class PaymentExceptionHandler {
@ExceptionHandler(PaymentDeclinedException.class)
public ResponseEntity<ErrorResponse> handleDeclined(
PaymentDeclinedException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
.body(ErrorResponse.of(402, "Payment Required",
ex.getMessage(), req.getRequestURI()));
}
@ExceptionHandler(InsufficientFundsException.class)
public ResponseEntity<ErrorResponse> handleInsufficientFunds(
InsufficientFundsException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(ErrorResponse.of(422, "Insufficient Funds",
ex.getMessage(), req.getRequestURI()));
}
}
// ── Scoped by base package class ──────────────────────────────────────
@RestControllerAdvice(basePackageClasses = AdminController.class)
public class AdminExceptionHandler {
@ExceptionHandler(AdminOperationException.class)
public ResponseEntity<ErrorResponse> handleAdminError(
AdminOperationException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse.of(403, "Forbidden",
ex.getMessage(), req.getRequestURI()));
}
}Handler Ordering with @Order
When multiple @RestControllerAdvice classes can handle the same exception type, Spring applies them in order. Use @Order or implement Ordered to control precedence. Lower order values run first. Specific handlers should run before the general catch-all.
Java
// ── Most specific — runs first ────────────────────────────────────────
@RestControllerAdvice
@Order(1)
@Slf4j
public class DomainExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(
UserNotFoundException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(404, "Not Found",
ex.getMessage(), req.getRequestURI()));
}
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(
OrderNotFoundException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(404, "Not Found",
ex.getMessage(), req.getRequestURI()));
}
}
// ── Spring MVC infrastructure exceptions — runs second ────────────────
@RestControllerAdvice
@Order(2)
public class MvcExceptionHandler {
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse> handleMethodNotAllowed(
HttpRequestMethodNotSupportedException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ErrorResponse.of(405, "Method Not Allowed",
ex.getMessage(), req.getRequestURI()));
}
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
public ResponseEntity<ErrorResponse> handleNotAcceptable(
HttpMediaTypeNotAcceptableException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE)
.body(ErrorResponse.of(406, "Not Acceptable",
"Requested media type is not supported",
req.getRequestURI()));
}
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ErrorResponse> handleNoHandler(
NoHandlerFoundException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(404, "Not Found",
"No endpoint: " + ex.getHttpMethod()
+ " " + ex.getRequestURL(),
req.getRequestURI()));
}
}
// ── General catch-all — runs last ─────────────────────────────────────
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
@Slf4j
public class FallbackExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(
Exception ex, HttpServletRequest req) {
log.error("Unhandled exception at {} {}",
req.getMethod(), req.getRequestURI(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of(500, "Internal Server Error",
"An unexpected error occurred", req.getRequestURI()));
}
}Exception Logging Strategy
Log at the right level for each exception category. Client errors (4xx) are expected — log at WARN with no stack trace. Server errors (5xx) are unexpected — log at ERROR with the full stack trace. Include the request method, URI, and a correlation ID so logs are traceable across distributed systems.
Java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// ── 4xx — client error: WARN, no stack trace ──────────────────────
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex, HttpServletRequest req) {
log.warn("NOT FOUND {} {}: {}",
req.getMethod(), req.getRequestURI(), ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(404, "Not Found",
ex.getMessage(), req.getRequestURI()));
}
@ExceptionHandler(BusinessRuleException.class)
public ResponseEntity<ErrorResponse> handleBusinessRule(
BusinessRuleException ex, HttpServletRequest req) {
log.warn("BUSINESS RULE {} {}: {}",
req.getMethod(), req.getRequestURI(), ex.getMessage());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(ErrorResponse.of(422, "Unprocessable Entity",
ex.getMessage(), req.getRequestURI()));
}
// ── 5xx — server error: ERROR with full stack trace ───────────────
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(
Exception ex, HttpServletRequest req) {
String correlationId = getCorrelationId(req);
log.error("[{}] UNHANDLED {} {}", correlationId,
req.getMethod(), req.getRequestURI(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of(500, "Internal Server Error",
"An unexpected error occurred — ref: " + correlationId,
req.getRequestURI()));
}
// ── Extract correlation ID from MDC or header ─────────────────────
private String getCorrelationId(HttpServletRequest req) {
String id = req.getHeader("X-Correlation-ID");
if (id == null) id = MDC.get("correlationId");
if (id == null) id = UUID.randomUUID().toString();
return id;
}
}
// ── MDC filter — set correlationId for every request ─────────────────
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String id = Optional.ofNullable(req.getHeader("X-Correlation-ID"))
.orElse(UUID.randomUUID().toString());
MDC.put("correlationId", id);
res.setHeader("X-Correlation-ID", id);
try {
chain.doFilter(req, res);
} finally {
MDC.clear();
}
}
}Security Exception Handling
Spring Security exceptions — AuthenticationException and AccessDeniedException — are thrown before the request reaches a controller, so @RestControllerAdvice cannot catch them. Handle them by implementing AuthenticationEntryPoint and AccessDeniedHandler and registering them on the SecurityFilterChain.
Java
// ── 401 AuthenticationEntryPoint ─────────────────────────────────────
@Component
@RequiredArgsConstructor
public class RestAuthenticationEntryPoint
implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest req,
HttpServletResponse res,
AuthenticationException ex)
throws IOException {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(res.getWriter(),
ErrorResponse.of(401, "Unauthorized",
"Authentication is required to access this resource",
req.getRequestURI()));
}
}
// ── 403 AccessDeniedHandler ───────────────────────────────────────────
@Component
@RequiredArgsConstructor
public class RestAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest req,
HttpServletResponse res,
AccessDeniedException ex)
throws IOException {
res.setStatus(HttpStatus.FORBIDDEN.value());
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(res.getWriter(),
ErrorResponse.of(403, "Forbidden",
"You do not have permission to access this resource",
req.getRequestURI()));
}
}
// ── Register on SecurityFilterChain ───────────────────────────────────
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final RestAuthenticationEntryPoint authEntryPoint;
private final RestAccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
return http
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.anyRequest().authenticated())
.build();
}
}