Spring Boot

Global Exception Handling

A controller advice turns exceptions into consistent HTTP error responses in one place instead of try/catch blocks in every controller. It is also easy to get subtly wrong: a catch-all handler that shadows specific ones, security exceptions swallowed into 500s, async failures that vanish without a trace. This entry covers the base advice and Spring MVC's built-in exceptions, scoping advice to controllers, how handler order is actually resolved, logging with correlation ids, Spring Security integration, and exceptions in async code.

@RestControllerAdvice Setup

A controller advice maps exceptions to HTTP responses in one place. @RestControllerAdvice is @ControllerAdvice plus @ResponseBody, so whatever a handler returns is written to the response body. Extend ResponseEntityExceptionHandler. It already handles the exceptions Spring MVC throws on its own: malformed JSON, missing parameters, type mismatches, unsupported methods and media types, validation failures and async timeouts. Without it, all of those land in your catch-all and come back as 500s, or you end up writing a dozen handlers by hand. One consequence is that you override its protected methods (as done below for validation) instead of declaring your own @ExceptionHandler for the same exception. A duplicate mapping fails at startup with "Ambiguous @ExceptionHandler method". Keep the domain handlers and the catch-all in the same class. Inside one class Spring picks the handler for the closest exception type, so the catch-all cannot steal anything from the specific handlers. Splitting them across classes is where ordering bugs start, and the ordering section below explains why. Be careful with ex.getMessage(). For your own exceptions it is written for the client. For library exceptions (JPA, JDBC, Jackson) it can contain class names, SQL and values, so map those to a fixed message.
Java
// ErrorResponse.java
// Spring 6 also ships org.springframework.web.ErrorResponse. Import yours explicitly.
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ErrorResponse(
        Instant timestamp,
        int status,
        String error,
        String message,
        String path,
        Map<String, String> fieldErrors) {

    public static ErrorResponse of(HttpStatus status, String message, String path) {
        return new ErrorResponse(Instant.now(), status.value(),
                status.getReasonPhrase(), message, path, Map.of());
    }

    public ErrorResponse withFieldErrors(Map<String, String> fieldErrors) {
        return new ErrorResponse(timestamp, status, error, message, path, fieldErrors);
    }
}

// GlobalExceptionHandler.java
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException ex, HttpServletRequest req) {
        return respond(HttpStatus.NOT_FOUND, ex.getMessage(), req);
    }

    @ExceptionHandler(DuplicateResourceException.class)
    public ResponseEntity<ErrorResponse> handleDuplicate(
            DuplicateResourceException ex, HttpServletRequest req) {
        return respond(HttpStatus.CONFLICT, ex.getMessage(), req);
    }

    @ExceptionHandler(BusinessRuleException.class)
    public ResponseEntity<ErrorResponse> handleBusinessRule(
            BusinessRuleException ex, HttpServletRequest req) {
        return respond(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), req);
    }

    // Library exception: fixed message, the real cause stays in the logs
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleIntegrity(
            DataIntegrityViolationException ex, HttpServletRequest req) {
        log.warn("{} {} violated a data constraint", req.getMethod(), req.getRequestURI(), ex);
        return respond(HttpStatus.CONFLICT, "The request conflicts with existing data", req);
    }

    // Overrides the base class instead of adding a second mapping for the same exception
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {

        Map<String, String> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(
                        FieldError::getField,
                        fe -> Objects.requireNonNullElse(fe.getDefaultMessage(), "Invalid value"),
                        (first, second) -> first));

        ErrorResponse body = ErrorResponse
                .of(HttpStatus.BAD_REQUEST, "One or more fields failed validation", pathOf(request))
                .withFieldErrors(fieldErrors);
        return handleExceptionInternal(ex, body, headers, status, request);
    }

    // Every other MVC exception (405, 415, malformed JSON, missing params...) comes through
    // here with a ProblemDetail body. Convert it so the whole API has one error shape.
    @Override
    protected ResponseEntity<Object> createResponseEntity(
            Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
        if (body instanceof ProblemDetail problem) {
            body = ErrorResponse.of(HttpStatus.valueOf(statusCode.value()),
                    problem.getDetail(), pathOf(request));
        }
        return super.createResponseEntity(body, headers, statusCode, request);
    }

    // Last resort. Security exceptions need a pass-through handler, see "Security Exceptions".
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex, HttpServletRequest req) {
        log.error("Unhandled exception for {} {}", req.getMethod(), req.getRequestURI(), ex);
        return respond(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred", req);
    }

    private static ResponseEntity<ErrorResponse> respond(
            HttpStatus status, String message, HttpServletRequest req) {
        return ResponseEntity.status(status)
                .body(ErrorResponse.of(status, message, req.getRequestURI()));
    }

    private static String pathOf(WebRequest request) {
        return request instanceof ServletWebRequest web ? web.getRequest().getRequestURI() : "";
    }
}

// Alternative with no custom error class: RFC 9457 ProblemDetail.
// Drop the createResponseEntity override and MVC errors already come back as ProblemDetail
// (application/problem+json). Return it from your own handlers too:
//
//   @ExceptionHandler(ResourceNotFoundException.class)
//   public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
//       return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
//   }

Scoping Advice to Specific Controllers

By default an advice applies to every controller. basePackages, assignableTypes and annotations narrow it, which helps when an API version needs a different error shape or one module has exceptions nobody else throws. Two things to watch. Scoped advice takes part in the ordering rules, so give it a lower @Order value than the global advice, and give overlapping scopes different values, because equal values leave the order undefined. Also, scope is decided by the controller that handled the request. Failures that happen before a controller is chosen, such as a 404 for an unknown path, are never seen by scoped advice. Only the global advice handles those.
Java
// Every controller under this package; v1 clients expect the old error shape
@RestControllerAdvice(basePackages = "com.myapp.api.v1")
@Order(10)
public class V1ExceptionHandler {

    @ExceptionHandler(LegacyApiException.class)
    public ResponseEntity<LegacyErrorResponse> handleLegacy(LegacyApiException ex) {
        return ResponseEntity.badRequest()
                .body(new LegacyErrorResponse(ex.getCode(), ex.getMessage()));
    }
}

// Specific controller classes
@RestControllerAdvice(assignableTypes = {PaymentController.class, RefundController.class})
@Order(20)
public class PaymentExceptionHandler {

    @ExceptionHandler(PaymentDeclinedException.class)
    public ResponseEntity<ErrorResponse> handleDeclined(
            PaymentDeclinedException ex, HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
                .body(ErrorResponse.of(HttpStatus.PAYMENT_REQUIRED, ex.getMessage(), req.getRequestURI()));
    }

    @ExceptionHandler(InsufficientFundsException.class)
    public ResponseEntity<ErrorResponse> handleInsufficientFunds(
            InsufficientFundsException ex, HttpServletRequest req) {
        return ResponseEntity.unprocessableEntity()
                .body(ErrorResponse.of(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), req.getRequestURI()));
    }
}

// Any controller carrying a marker annotation
@RestControllerAdvice(annotations = AdminApi.class)
@Order(30)
public class AdminExceptionHandler {

    @ExceptionHandler(AdminOperationException.class)
    public ResponseEntity<ErrorResponse> handleAdminError(
            AdminOperationException ex, HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ErrorResponse.of(HttpStatus.FORBIDDEN, ex.getMessage(), req.getRequestURI()));
    }
}

How Handler Order Is Resolved

When several advice classes exist, the order is easy to misread. Spring does not collect every matching handler and choose the best one. It walks the advice beans in @Order sequence, lowest value first, and uses the first advice that has any handler able to take the exception. Later advice is not consulted, even if it has a closer match. This is why a catch-all for Exception must live in the last advice, or in the same class as the specific handlers. Put it in an early advice and it answers everything, and the handlers behind it never run. The failure is silent: no error at startup, just a 500 where you expected a 404. Inside a single class the rule is different and friendlier: the handler for the closest exception type wins. Handlers declared on the controller itself are checked before any advice, so they are a good place for an exception only that controller throws.
Java
// How Spring picks a handler for an exception thrown by PaymentController
//
//  1. @ExceptionHandler methods inside PaymentController itself. These always win.
//  2. Advice beans sorted by @Order, lowest value first. The FIRST advice that has any
//     handler able to take the exception is used. Later advice is never consulted,
//     even if it has a closer match.
//  3. Inside that advice class, the handler for the closest exception type wins.
//     If none matches the exception itself, the lookup is retried with ex.getCause().
//  4. Nothing found: the exception propagates out of the DispatcherServlet.

// Broken: Exception matches everything, so Specific is never reached
@RestControllerAdvice
@Order(1)
class CatchAllFirst {
    @ExceptionHandler(Exception.class)
    ResponseEntity<ErrorResponse> handleAll(Exception ex, HttpServletRequest req) {
        return ResponseEntity.status(500)
                .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error", req.getRequestURI()));
    }
}

@RestControllerAdvice
@Order(2)
class Specific {
    @ExceptionHandler(OrderNotFoundException.class)      // dead code
    ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex, HttpServletRequest req) {
        return ResponseEntity.status(404)
                .body(ErrorResponse.of(HttpStatus.NOT_FOUND, ex.getMessage(), req.getRequestURI()));
    }
}

// Working layout: narrow advice first, the broad one last
@RestControllerAdvice(assignableTypes = PaymentController.class)
@Order(20)
class PaymentExceptionHandler { /* PaymentDeclinedException, InsufficientFundsException */ }

@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    /* domain handlers, MVC overrides and the Exception.class catch-all */
}

// Controller-local handler: for an exception only this controller throws
@RestController
@RequestMapping("/api/v1/imports")
class ImportController {

    @ExceptionHandler(ImportFormatException.class)
    ResponseEntity<ErrorResponse> handleFormat(ImportFormatException ex, HttpServletRequest req) {
        return ResponseEntity.unprocessableEntity()
                .body(ErrorResponse.of(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), req.getRequestURI()));
    }
}

// To see which handler answered a request, turn on:
//   logging.level.org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver=DEBUG

Logging and Correlation IDs

Log in the handler, once. Logging where the exception is thrown and again in the handler gives you duplicate entries with the same stack trace. A 4xx is the client's mistake, so skip the stack trace. INFO is usually enough. WARN is fine if you alert on it, but a crawler requesting unknown URLs all day will bury the warnings that matter. A 5xx is your bug: ERROR, with the stack trace. Give each request an id so a user's complaint can be matched to a log line. If you use Micrometer Tracing, the trace id is already in the MDC and Boot 3.2+ includes it in the default log pattern, so use that. Otherwise a small filter does the job. Two details matter. Validate an incoming id before logging it, since an unchecked header can carry newlines that forge log lines, or just be huge. And remove only your own key in finally rather than calling MDC.clear(), which wipes keys set by other code. Return the id to the client in the 500 response so support can ask for it. Do not return the exception message or stack trace.
Java
// In GlobalExceptionHandler

@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
        ResourceNotFoundException ex, HttpServletRequest req) {
    log.info("{} {} -> 404: {}", req.getMethod(), req.getRequestURI(), ex.getMessage());
    return respond(HttpStatus.NOT_FOUND, ex.getMessage(), req);
}

@ExceptionHandler(BusinessRuleException.class)
public ResponseEntity<ErrorResponse> handleBusinessRule(
        BusinessRuleException ex, HttpServletRequest req) {
    log.info("{} {} -> 422: {}", req.getMethod(), req.getRequestURI(), ex.getMessage());
    return respond(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), req);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex, HttpServletRequest req) {
    log.error("{} {} -> 500", req.getMethod(), req.getRequestURI(), ex);   // stack trace included
    String ref = Objects.toString(MDC.get("correlationId"), "n/a");
    return respond(HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred. Reference: " + ref, req);
}

// CorrelationIdFilter.java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)      // before Spring Security, so 401s carry an id too
public class CorrelationIdFilter extends OncePerRequestFilter {

    private static final String HEADER = "X-Correlation-ID";
    private static final String KEY = "correlationId";
    private static final Pattern SAFE = Pattern.compile("[A-Za-z0-9._-]{1,64}");

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String incoming = req.getHeader(HEADER);
        String id = (incoming != null && SAFE.matcher(incoming).matches())
                ? incoming
                : UUID.randomUUID().toString();

        MDC.put(KEY, id);
        res.setHeader(HEADER, id);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.remove(KEY);
        }
    }
}

// application.yml: print the id on every line
//   logging:
//     pattern:
//       level: "%5p [%X{correlationId:-}]"

Security Exceptions

Spring Security exceptions reach your code by two different routes, and they need different handling. Failures decided by the filter chain, such as a missing token or a URL rule in authorizeHttpRequests, are thrown inside a filter before any controller runs, so controller advice never sees them. ExceptionTranslationFilter converts them into 401 or 403 using the AuthenticationEntryPoint and AccessDeniedHandler you register on the SecurityFilterChain. Exceptions thrown by your own filters are not covered either. Catch them in the filter, or hand them to the entry point. Failures from method security (@PreAuthorize, @Secured) are thrown inside the controller call, so controller advice does see them. With a catch-all Exception handler, a 403 turns into a 500. Re-throw them from the advice and let the filter chain answer. That also keeps the 401 versus 403 decision where it belongs: the filter chain returns 401 to an anonymous caller and 403 to an authenticated one, while an advice that returns 403 directly would tell someone who simply is not logged in that they are forbidden.
Java
// In GlobalExceptionHandler: let Spring Security answer for itself.
// AccessDeniedException here is org.springframework.security.access.AccessDeniedException.
@ExceptionHandler({AccessDeniedException.class, AuthenticationException.class})
public void passSecurityExceptionsThrough(RuntimeException ex) {
    throw ex;
}

// 401: no valid credentials
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public RestAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void commence(HttpServletRequest req, HttpServletResponse res,
                         AuthenticationException ex) throws IOException {
        res.setStatus(HttpStatus.UNAUTHORIZED.value());
        res.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer");   // use the scheme you accept
        res.setContentType(MediaType.APPLICATION_JSON_VALUE);
        objectMapper.writeValue(res.getOutputStream(), ErrorResponse.of(
                HttpStatus.UNAUTHORIZED,
                "Authentication is required to access this resource",
                req.getRequestURI()));
    }
}

// 403: authenticated, but not allowed
@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    public RestAccessDeniedHandler(ObjectMapper objectMapper) {
        this.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.getOutputStream(), ErrorResponse.of(
                HttpStatus.FORBIDDEN,
                "You do not have permission to access this resource",
                req.getRequestURI()));
    }
}

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http,
                                    RestAuthenticationEntryPoint entryPoint,
                                    RestAccessDeniedHandler deniedHandler) throws Exception {
        return http
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(entryPoint)
                        .accessDeniedHandler(deniedHandler))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/v1/auth/**").permitAll()
                        .anyRequest().authenticated())
                .build();
    }
}

// Both routes now produce the same JSON:
//   anonymous GET /api/v1/orders                  -> filter chain        -> 401 via entry point
//   user without role calls @PreAuthorize method  -> advice re-throws    -> 403 via access denied handler

Async Exception Handling

Exceptions in async code behave differently depending on how the work was started, and in some cases they disappear. If a controller returns CompletableFuture, Callable or DeferredResult, a failure is dispatched back through Spring MVC and your advice handles it like any other. Spring unwraps CompletionException first, so handlers see the original exception type. A timeout surfaces as AsyncRequestTimeoutException (503), which ResponseEntityExceptionHandler already covers. The timeout itself is set with spring.mvc.async.request-timeout. An @Async method returning void has no caller to receive the exception. Register an AsyncUncaughtExceptionHandler, otherwise Spring only logs it at ERROR from the executor thread with no request context. An @Async method returning a CompletableFuture stores the failure in the future, so the caller decides what to do with it, and that is where things go quiet: a future nobody reads swallows its exception. Always attach whenComplete or exceptionally to fire-and-forget work. Note that in exceptionally and whenComplete the exception is often wrapped in a CompletionException. Async threads also do not inherit the request's MDC, so logs from them lose the correlation id unless you copy it with a TaskDecorator. The security context is not propagated either. Wrap the executor with DelegatingSecurityContextAsyncTaskExecutor if async code needs the current user.
Java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.initialize();
        return executor;
    }

    // Only reached by @Async methods that return void
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) ->
                log.error("Uncaught exception in @Async method {}", method.toGenericString(), ex);
    }
}

// Copies the caller's MDC (correlation id) onto the worker thread
public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable task) {
        Map<String, String> context = MDC.getCopyOfContextMap();
        return () -> {
            Map<String, String> previous = MDC.getCopyOfContextMap();
            try {
                if (context != null) MDC.setContextMap(context); else MDC.clear();
                task.run();
            } finally {
                if (previous != null) MDC.setContextMap(previous); else MDC.clear();
            }
        };
    }
}

@Service
public class ReportService {

    private static final Logger log = LoggerFactory.getLogger(ReportService.class);

    @Async
    public void sendReportEmail(long reportId) {
        // void: a failure goes to the AsyncUncaughtExceptionHandler above
    }

    @Async
    public CompletableFuture<ReportDto> buildReport(long id) {
        // a failure is stored in the returned future
        return CompletableFuture.completedFuture(generate(id));
    }

    public void startImport(Path file) {
        // Silent failure: nobody reads the future, so an exception just disappears
        //   CompletableFuture.runAsync(() -> importer.run(file), executor);

        CompletableFuture.runAsync(() -> importer.run(file), executor)
                .whenComplete((result, ex) -> {
                    if (ex != null) {
                        log.error("Import of {} failed", file, ex);
                    }
                });
    }
}

@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {

    private final ReportService reports;

    public ReportController(ReportService reports) {
        this.reports = reports;
    }

    // A failure inside buildReport still reaches GlobalExceptionHandler,
    // e.g. a ResourceNotFoundException becomes a 404.
    @GetMapping("/{id}")
    public CompletableFuture<ReportDto> report(@PathVariable long id) {
        return reports.buildReport(id);
    }
}

// application.yml
//   spring:
//     mvc:
//       async:
//         request-timeout: 30s     # exceeded -> AsyncRequestTimeoutException -> 503