Skip to content

@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:

GlobalExceptionHandler.java
@RestControllerAdvice
public 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:

UserController.java
@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);
}
}
OrderController.java
@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 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

GlobalExceptionHandler.java
@RestControllerAdvice
public 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

UserController.java
@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
|
v
Controller @ExceptionHandler Methods
|
| (No match found)
v
@ControllerAdvice Classes (by @Order)
|
| (No match found)
v
Spring Default Error Handling

Local @ExceptionHandler methods take precedence over global ones. This means your controller can override global handlers when needed:

PaymentController.java
@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:

ApiExceptionHandler.java
// 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:

ErrorCodeExceptionHandler.java
@Order(1)
@RestControllerAdvice
public 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)
@RestControllerAdvice
public 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)
@RestControllerAdvice
public 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:

ExceptionHierarchy.java
// Base exception for all domain exceptions
public 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 exceptions
public 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:

ErrorResponse.java
public class ErrorResponse {
private final String code;
private final String message;
private final List&lt;String&gt; details;
private final Instant timestamp;
public ErrorResponse(String code, String message) {
this(code, message, null);
}
public ErrorResponse(String code, String message, List&lt;String&gt; 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:

GoodExceptionHandler.java
@ExceptionHandler(InsufficientStockException.class)
public ResponseEntity&lt;ErrorResponse&gt; 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:

SecureExceptionHandler.java
@ExceptionHandler(Exception.class)
public ResponseEntity&lt;ErrorResponse&gt; 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments