Spring BootGlobal Exception Handling
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();
    }
}