Validation & Exception Handling

Global Handling with @ControllerAdvice

18 min Lesson 7 of 13

Global Handling with @ControllerAdvice

In the previous lesson you saw how @ExceptionHandler methods placed inside a controller class can intercept exceptions thrown by that controller. That works, but it does not scale: if you have twenty controllers and want a consistent error shape across all of them, copying the same handler into each class is brittle and violates the DRY principle. The moment a business rule changes — say the team decides to add a traceId field to every error response — you have to update twenty files.

@ControllerAdvice solves this by letting you extract those handler methods into a single, dedicated class that Spring automatically applies to all controllers in the application. Think of it as an interceptor that wraps every controller without the controllers knowing anything about it.

What @ControllerAdvice Actually Is

@ControllerAdvice is a stereotype annotation (it is itself meta-annotated with @Component), so Spring picks it up during component scanning and registers it as a special advisor. Internally, Spring weaves its methods into the request-handling pipeline via HandlerExceptionResolverComposite, which means advice methods are invoked after the controller method throws but before the response is committed.

A class annotated with @RestControllerAdvice — a convenient shortcut that combines @ControllerAdvice and @ResponseBody — is the idiomatic choice for REST APIs because every method's return value is automatically serialized to JSON without needing an explicit @ResponseBody on each handler.

@ControllerAdvice vs @RestControllerAdvice: Use @ControllerAdvice when your application mixes MVC views and REST endpoints, so some handlers may return ModelAndView. Use @RestControllerAdvice when you are building a pure REST/JSON API — it eliminates boilerplate on every method.

A Minimal Global Exception Handler

Here is the simplest complete example — a class that handles two common exceptions for the entire application:

package com.example.api.exception; import com.example.api.dto.ErrorResponse; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorResponse handleNotFound(ResourceNotFoundException ex) { return new ErrorResponse("NOT_FOUND", ex.getMessage()); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse handleGeneric(Exception ex) { // Never expose internal details to clients in production return new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred."); } }

The ErrorResponse record used above is a plain data carrier:

package com.example.api.dto; public record ErrorResponse(String code, String message) {}

With this single class in place, any controller that throws ResourceNotFoundException will receive a 404 with a consistent JSON body, and any unhandled exception will produce a 500 — no per-controller setup required.

Handling Validation Errors from @Valid

When a method parameter annotated with @Valid or @Validated fails Bean Validation, Spring throws MethodArgumentNotValidException for request bodies and ConstraintViolationException for path/query parameters. These exceptions carry the full set of field errors. The global handler is the right place to translate them into a useful error response:

import jakarta.validation.ConstraintViolationException; import org.springframework.http.HttpStatus; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.List; import java.util.stream.Collectors; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) // 422 public ValidationErrorResponse handleValidation(MethodArgumentNotValidException ex) { List<FieldViolation> violations = ex.getBindingResult() .getFieldErrors() .stream() .map(e -> new FieldViolation(e.getField(), e.getDefaultMessage())) .collect(Collectors.toList()); return new ValidationErrorResponse("VALIDATION_FAILED", violations); } @ExceptionHandler(ConstraintViolationException.class) @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) public ValidationErrorResponse handleConstraintViolation(ConstraintViolationException ex) { List<FieldViolation> violations = ex.getConstraintViolations() .stream() .map(v -> new FieldViolation( v.getPropertyPath().toString(), v.getMessage())) .collect(Collectors.toList()); return new ValidationErrorResponse("VALIDATION_FAILED", violations); } // ... other handlers }
Why 422 instead of 400? HTTP 400 (Bad Request) means the request is syntactically malformed — unparseable JSON, wrong content-type, etc. HTTP 422 (Unprocessable Entity) means the request was parsed correctly but failed semantic validation. Returning 422 for bean-validation errors gives API consumers a clear, machine-readable signal to distinguish malformed requests from constraint failures. That said, many public APIs use 400 for both; the important thing is to be consistent.

Narrowing the Scope of @ControllerAdvice

By default, @ControllerAdvice applies to every controller in the application context. You can narrow its scope using attributes on the annotation:

// Only applies to controllers in this package (and sub-packages) @RestControllerAdvice(basePackages = "com.example.api.orders") public class OrderExceptionHandler { ... } // Only applies to controllers annotated with @RestController @ControllerAdvice(annotations = RestController.class) public class RestOnlyHandler { ... } // Only applies to a specific set of controller classes @ControllerAdvice(assignableTypes = { UserController.class, ProfileController.class }) public class UserDomainHandler { ... }

Narrowing is useful in large modular applications where different domains have different error vocabularies — the orders domain might return OrderError objects while the billing domain returns BillingError objects. A single god-class handler quickly becomes a maintenance burden; splitting by package or domain keeps each handler focused.

Handler Precedence When Multiple Advice Classes Exist

Spring applies @ExceptionHandler methods in a well-defined order. The lookup starts with the most specific exception type and works up the inheritance hierarchy. When two advice classes both declare a handler for the same exception type, Spring uses the order determined by @Order or by implementing Ordered:

import org.springframework.core.annotation.Order; @RestControllerAdvice @Order(1) // Lower number = higher priority public class DomainExceptionHandler { @ExceptionHandler(ResourceNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorResponse handleNotFound(ResourceNotFoundException ex) { return new ErrorResponse("NOT_FOUND", ex.getMessage()); } } @RestControllerAdvice @Order(2) // Fallback: handles anything not caught above public class FallbackExceptionHandler { @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ErrorResponse handleAll(Exception ex) { return new ErrorResponse("INTERNAL_ERROR", "Unexpected error."); } }
Avoid catching Exception or Throwable too early. If your highest-priority advice contains @ExceptionHandler(Exception.class), it will swallow every exception — including Spring's own MethodArgumentNotValidException or NoHandlerFoundException — before any more specific handler can process it. Keep the generic fallback in the lowest-priority (highest @Order number) advice class.

Injecting Request Context into Handlers

Handler methods in a @ControllerAdvice class support the same parameter injection as controller methods. This is extremely useful for correlating errors with incoming requests:

@ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound( ResourceNotFoundException ex, HttpServletRequest request, WebRequest webRequest) { String path = request.getRequestURI(); String traceId = (String) request.getAttribute("X-Trace-Id"); ErrorResponse body = new ErrorResponse( "NOT_FOUND", ex.getMessage(), path, traceId ); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); }

Supported injectable parameters include HttpServletRequest, HttpServletResponse, WebRequest, Locale, and the exception itself. Using ResponseEntity<T> as the return type gives you full control over status, headers, and body without needing @ResponseStatus.

Extending ResponseEntityExceptionHandler

Spring MVC provides a base class, ResponseEntityExceptionHandler, that already declares handlers for all of Spring's own internal exceptions (MethodArgumentNotValidException, HttpMessageNotReadableException, HttpRequestMethodNotSupportedException, and about a dozen others). Extending it gives you consistent handling of the framework-level exceptions for free; you only need to override the methods you want to customize:

import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @RestControllerAdvice public class ApiExceptionHandler extends ResponseEntityExceptionHandler { @Override protected ResponseEntity<Object> handleMethodArgumentNotValid( MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { List<String> errors = ex.getBindingResult().getFieldErrors() .stream() .map(FieldError::getDefaultMessage) .toList(); return ResponseEntity.unprocessableEntity() .body(new ValidationErrorResponse("VALIDATION_FAILED", errors)); } // Add @ExceptionHandler methods for your own domain exceptions below }
ResponseEntityExceptionHandler is the preferred starting point for production REST APIs. Without it you would have to manually handle every Spring MVC exception (missing path variable, unsupported media type, etc.) yourself. Extending it and overriding only what you need keeps the handler concise.

Summary

@RestControllerAdvice is the cornerstone of a maintainable exception-handling strategy in Spring Boot. It centralizes all error responses in one place, enforces a consistent error shape across every endpoint, and eliminates the need to duplicate handler code in each controller. The key design decisions are: always extend ResponseEntityExceptionHandler as your base class, keep the generic Exception catch-all in the lowest-priority advice, use 422 for bean-validation failures, and inject HttpServletRequest to include path and trace context in error responses. The next lesson builds on this foundation by designing a production-ready, consistent error response contract.