@ControllerAdvice vs Per-Controller Exception Handlers: Best Practices
You’re building a Spring Boot REST API, and you need to handle exceptions. Where do you put your exception handlers? Do you create a centralized @ControllerAdvice class that catches everything? Or do you handle exceptions locally within each controller using @ExceptionHandler methods?
This architectural decision impacts your codebase maintainability, readability, and team velocity. I’ve seen both approaches used incorrectly, leading to either monolithic exception handling classes or scattered duplicated error handling code.
Let me walk you through each approach, their trade-offs, and when to use which.
Centralized @ControllerAdvice Approach
The centralized approach uses one or a few @ControllerAdvice classes to handle all exceptions across your application:
@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationErrors( MethodArgumentNotValidException ex) { List<String> errors = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.toList());
ErrorResponse response = new ErrorResponse( "VALIDATION_FAILED", "Input validation failed", errors ); return ResponseEntity.badRequest().body(response); }
@ExceptionHandler(AccessDeniedException.class) public ResponseEntity<ErrorResponse> handleAccessDenied( AccessDeniedException ex) { ErrorResponse response = new ErrorResponse( "ACCESS_DENIED", "You don't have permission to access this resource" ); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); }
@ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleGenericException( Exception ex) { ErrorResponse response = new ErrorResponse( "INTERNAL_ERROR", "An unexpected error occurred" ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(response); }}Benefits of Centralized Approach
Single source of truth: All exception handling logic lives in one place. New team members know exactly where to look when debugging error responses.
Consistency: Error response format is guaranteed to be uniform across all endpoints. No risk of one controller returning errors differently than another.
DRY principle: No code duplication. Validation error handling, authentication errors, and generic 500 errors are written once.
Easy maintenance: Change the error response format in one place, and it applies everywhere.
Risks of Centralized Approach
God class problem: I’ve seen @ControllerAdvice classes grow to 500+ lines, handling dozens of exception types. This becomes a maintenance nightmare.
Loss of context: When all exceptions are handled globally, you lose the domain context. A UserNotFoundException in a user registration flow might need different error messages than the same exception in an admin user search.
Over-coupling: Your exception handling becomes a catch-all that knows about every exception type in your system, creating tight coupling across domains.
Per-Controller Exception Handlers
With this approach, each controller handles its own exceptions using @ExceptionHandler methods:
@RestController@RequestMapping("/api/users")public class UserController {
private final UserService userService;
@PostMapping public ResponseEntity<UserResponse> createUser( @Valid @RequestBody CreateUserRequest request) { UserResponse user = userService.createUser(request); return ResponseEntity.status(HttpStatus.CREATED).body(user); }
@ExceptionHandler(UserAlreadyExistsException.class) public ResponseEntity<ErrorResponse> handleUserAlreadyExists( UserAlreadyExistsException ex) { ErrorResponse response = new ErrorResponse( "USER_ALREADY_EXISTS", "User with email " + ex.getEmail() + " already exists" ); return ResponseEntity.status(HttpStatus.CONFLICT).body(response); }
@ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ErrorResponse> handleUserNotFound( UserNotFoundException ex) { ErrorResponse response = new ErrorResponse( "USER_NOT_FOUND", "User with ID " + ex.getUserId() + " not found" ); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); }}@RestController@RequestMapping("/api/orders")public class OrderController {
private final OrderService orderService;
@PostMapping public ResponseEntity<OrderResponse> createOrder( @Valid @RequestBody CreateOrderRequest request) { OrderResponse order = orderService.createOrder(request); return ResponseEntity.status(HttpStatus.CREATED).body(order); }
@ExceptionHandler(InsufficientStockException.class) public ResponseEntity<ErrorResponse> handleInsufficientStock( InsufficientStockException ex) { ErrorResponse response = new ErrorResponse( "INSUFFICIENT_STOCK", "Product " + ex.getProductId() + " has only " + ex.getAvailableStock() + " items available" ); return ResponseEntity.status(HttpStatus.CONFLICT).body(response); }
@ExceptionHandler(OrderNotFoundException.class) public ResponseEntity<ErrorResponse> handleOrderNotFound( OrderNotFoundException ex) { ErrorResponse response = new ErrorResponse( "ORDER_NOT_FOUND", "Order " + ex.getOrderId() + " not found" ); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); }}Benefits of Per-Controller Approach
High cohesion: Each controller handles exceptions relevant to its domain. The UserController knows about user-specific errors, and nothing else.
Domain-specific messaging: You can craft error messages that make sense in the specific context. A NotFoundException in the user context becomes “User not found” while in the order context it becomes “Order not found”.
Independent evolution: You can change how one controller handles errors without affecting others. Teams working on different domains don’t need to coordinate on exception handling changes.
Risks of Per-Controller Approach
Code duplication: Validation error handling, authentication errors, and other cross-cutting concerns get duplicated across controllers.
Inconsistency: Without strict guidelines, different controllers might format errors differently. One controller returns {"error": "message"} while another returns {"message": "error"}.
Scattered knowledge: When debugging an error, you need to figure out which controller handled it, making troubleshooting harder.
The Hybrid Approach (Recommended)
The best architecture often combines both approaches. Use centralized @ControllerAdvice for cross-cutting concerns, and per-controller handlers for domain-specific exceptions.
Here’s how I structure it:
Global Handler for Cross-Cutting Concerns
@RestControllerAdvicepublic class GlobalExceptionHandler {
// Validation errors - same everywhere @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationErrors( MethodArgumentNotValidException ex) { List<String> errors = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.toList());
return ResponseEntity.badRequest() .body(new ErrorResponse("VALIDATION_FAILED", "Validation failed", errors)); }
// Authentication errors - same everywhere @ExceptionHandler({ AuthenticationCredentialsNotFoundException.class, BadCredentialsException.class }) public ResponseEntity<ErrorResponse> handleAuthenticationErrors( RuntimeException ex) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(new ErrorResponse("UNAUTHORIZED", "Authentication required")); }
// Authorization errors - same everywhere @ExceptionHandler(AccessDeniedException.class) public ResponseEntity<ErrorResponse> handleAccessDenied( AccessDeniedException ex) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(new ErrorResponse("ACCESS_DENIED", "Access denied")); }
// Generic 500 errors - catch-all safety net @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleGenericException( Exception ex, WebRequest request) { // Log the actual error for debugging log.error("Unexpected error occurred", ex);
// Return generic error to client return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred")); }}Domain-Specific Handlers in Controllers
@RestController@RequestMapping("/api/users")public class UserController {
private final UserService userService;
@PostMapping public ResponseEntity<UserResponse> createUser( @Valid @RequestBody CreateUserRequest request) { return ResponseEntity.status(HttpStatus.CREATED) .body(userService.createUser(request)); }
// Domain-specific: user already exists @ExceptionHandler(UserAlreadyExistsException.class) public ResponseEntity<ErrorResponse> handleUserAlreadyExists( UserAlreadyExistsException ex) { return ResponseEntity.status(HttpStatus.CONFLICT) .body(new ErrorResponse( "USER_ALREADY_EXISTS", "User with email " + ex.getEmail() + " already exists" )); }
// Domain-specific: user not found in user operations @ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ErrorResponse> handleUserNotFound( UserNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(new ErrorResponse( "USER_NOT_FOUND", "User with ID " + ex.getUserId() + " not found" )); }}How Precedence Works
When an exception occurs, Spring follows this resolution chain:
Exception Thrown | vController @ExceptionHandler Methods | | (No match found) v@ControllerAdvice Classes (by @Order) | | (No match found) vSpring Default Error HandlingLocal @ExceptionHandler methods take precedence over global ones. This means your controller can override global handlers when needed:
@RestController@RequestMapping("/api/payments")public class PaymentController {
// Override generic global exception handler // for payment-specific context @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handlePaymentException( Exception ex) { // Log with payment context log.error("Payment processing error for transaction", ex);
// Return payment-specific error return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse( "PAYMENT_PROCESSING_ERROR", "Payment could not be processed. Please contact support." )); }}Advanced @ControllerAdvice Techniques
Using Selectors for Fine-Grained Control
You don’t have to apply @ControllerAdvice to all controllers. Use selectors to limit its scope:
// Only apply to controllers in specific package@RestControllerAdvice(basePackages = "com.myapp.api")public class ApiExceptionHandler { // ...}
// Only apply to controllers with specific annotation@RestControllerAdvice(annotations = RestController.class)public class RestExceptionHandler { // ...}
// Only apply to specific controller classes@RestControllerAdvice(assignableTypes = {UserController.class, OrderController.class})public class UserOrderExceptionHandler { // ...}Multiple Advice Classes with @Order
When you have multiple @ControllerAdvice classes, use @Order to control precedence:
@Order(1)@RestControllerAdvicepublic class ErrorCodeExceptionHandler {
// High priority: handles specific error codes @ExceptionHandler(ErrorCodeException.class) public ResponseEntity<ErrorResponse> handleErrorCode( ErrorCodeException ex) { return ResponseEntity.status(ex.getHttpStatus()) .body(new ErrorResponse(ex.getErrorCode(), ex.getMessage())); }}
@Order(2)@RestControllerAdvicepublic class DomainExceptionHandler {
// Medium priority: handles domain exceptions @ExceptionHandler(DomainException.class) public ResponseEntity<ErrorResponse> handleDomain( DomainException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(new ErrorResponse("DOMAIN_ERROR", ex.getMessage())); }}
@Order(Ordered.LOWEST_PRECEDENCE)@RestControllerAdvicepublic class FallbackExceptionHandler {
// Low priority: catch-all safety net @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleGeneric( Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse("INTERNAL_ERROR", "Unexpected error")); }}Decision Matrix
Here’s a decision matrix to help you choose the right approach:
+------------------+------------------------+------------------------+| Application Size | Recommended Approach | Why |+------------------+------------------------+------------------------+| Small app | Centralized | Simple, fast to set || (< 10 endpoints) | @ControllerAdvice | up, easy to understand |+------------------+------------------------+------------------------+| Medium app | Hybrid approach | Balance between || (10-50 | | consistency and || endpoints) | | domain specificity |+------------------+------------------------+------------------------+| Large enterprise | Hybrid with clear | Clear separation of || (50+ endpoints, | guidelines | concerns, team || multiple teams) | | autonomy |+------------------+------------------------+------------------------+| Microservices | Per-controller or | Service independence, || architecture | hybrid | bounded contexts |+------------------+------------------------+------------------------+Practical Guidelines
Based on my experience, here are practical guidelines to follow:
1. Start with centralized for cross-cutting concerns: Validation errors, authentication errors, and generic 500 errors should be handled globally. These are the same across all endpoints.
2. Use per-controller for domain exceptions: UserNotFoundException, InsufficientStockException, PaymentDeclinedException - these have domain context and should be handled close to where they’re thrown.
3. Keep global handlers small: If your global @ControllerAdvice exceeds 200 lines, split it by concern. Have one for validation, one for security, one for generic errors.
4. Use custom exception hierarchies: Create base exception classes and handle them at the appropriate level:
// Base exception for all domain exceptionspublic abstract class DomainException extends RuntimeException { private final String errorCode;
protected DomainException(String errorCode, String message) { super(message); this.errorCode = errorCode; }
public String getErrorCode() { return errorCode; }}
// Specific domain exceptionspublic class UserException extends DomainException { protected UserException(String errorCode, String message) { super(errorCode, message); }}
public class UserNotFoundException extends UserException { public UserNotFoundException(Long userId) { super("USER_NOT_FOUND", "User not found: " + userId); }}5. Log appropriately: Global handlers should log at ERROR level for unexpected exceptions. Local handlers can log at WARN or INFO level for expected business exceptions.
6. Return consistent error responses: Whether global or local, use the same response structure:
public class ErrorResponse { private final String code; private final String message; private final List<String> details; private final Instant timestamp;
public ErrorResponse(String code, String message) { this(code, message, null); }
public ErrorResponse(String code, String message, List<String> details) { this.code = code; this.message = message; this.details = details; this.timestamp = Instant.now(); }
// getters}Common Anti-Patterns to Avoid
1. Catching everything in one handler: Don’t create a single @ExceptionHandler(Exception.class) that tries to differentiate between exception types with instanceof checks. Create separate handlers for each exception type.
2. Ignoring exception context: Don’t lose the valuable context from your exceptions. Include relevant details in your error responses:
@ExceptionHandler(InsufficientStockException.class)public ResponseEntity<ErrorResponse> handleInsufficientStock( InsufficientStockException ex) { // GOOD: Include context from the exception ErrorResponse response = new ErrorResponse( "INSUFFICIENT_STOCK", String.format("Only %d items available for product %s", ex.getAvailableStock(), ex.getProductId()) ); return ResponseEntity.status(HttpStatus.CONFLICT).body(response);}3. Exposing internal errors: Don’t leak stack traces, SQL errors, or internal system details to clients. The global exception handler should catch these and return generic errors:
@ExceptionHandler(Exception.class)public ResponseEntity<ErrorResponse> handleGenericException( Exception ex, WebRequest request) { // Log internally with full details log.error("Unexpected error for request {}: {}", request.getDescription(false), ex.getMessage(), ex);
// Return generic error to client return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse( "INTERNAL_ERROR", "An unexpected error occurred. Please try again later." ));}Summary
The choice between centralized @ControllerAdvice and per-controller @ExceptionHandler isn’t binary. The best approach combines both:
- Global handlers for cross-cutting concerns: validation, authentication, authorization, generic errors
- Local handlers for domain-specific exceptions: business logic errors with context-specific messages
This hybrid approach gives you the consistency benefits of centralized handling while maintaining the contextual relevance of local handlers. Start with this structure, and your exception handling will scale cleanly as your application grows.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Spring Framework Documentation: Controller Advice
- 👨💻 Reddit Discussion: ControllerAdvice and RestControllerAdvice
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments