Skip to content

Stop @ControllerAdvice From Becoming a God Class in Spring Boot

I recently came across a Reddit thread that made me pause. Someone warned about @ControllerAdvice becoming a god class—a catch-all for every exception handler in the application. I looked at my own codebase and realized they were right. My GlobalExceptionHandler had grown to 15 methods, handling everything from validation errors to database constraints to security violations. It was time to refactor.

The God Class Problem

A god class is an anti-pattern where a single class knows too much or does too much. In the context of @ControllerAdvice, this happens when you dump all your exception handlers into one class:

GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
// User domain exceptions
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) { ... }
@ExceptionHandler(InvalidUserCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidUserCredentialsException ex) { ... }
// Payment domain exceptions
@ExceptionHandler(PaymentFailedException.class)
public ResponseEntity<ErrorResponse> handlePaymentFailed(PaymentFailedException ex) { ... }
@ExceptionHandler(InsufficientFundsException.class)
public ResponseEntity<ErrorResponse> handleInsufficientFunds(InsufficientFundsException ex) { ... }
// Order domain exceptions
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) { ... }
@ExceptionHandler(InvalidOrderStateException.class)
public ResponseEntity<ErrorResponse> handleInvalidOrderState(InvalidOrderStateException ex) { ... }
// ... 10 more handlers for other domains
}

This violates the Single Responsibility Principle and becomes a maintenance nightmare. Multiple developers end up editing the same file, merge conflicts increase, and the class becomes harder to understand.

The Solution: Spring’s Scoping Mechanisms

Spring provides three ways to scope @ControllerAdvice:

+------------------+---------------------------------------+
| Scoping Mechanism| What It Does |
+------------------+---------------------------------------+
| basePackages | Apply to controllers in specific |
| | package hierarchies |
+------------------+---------------------------------------+
| annotations | Apply to controllers with specific |
| | annotations |
+------------------+---------------------------------------+
| assignableTypes | Apply to specific controller classes |
+------------------+---------------------------------------+

Let’s explore each strategy.

Strategy 1: Package-Scoped Handlers

Use basePackages to create domain-specific exception handlers:

UserExceptionHandler.java
@RestControllerAdvice(basePackages = "com.example.user")
public class UserExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(InvalidUserCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidUserCredentialsException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("INVALID_CREDENTIALS", ex.getMessage()));
}
}
PaymentExceptionHandler.java
@RestControllerAdvice(basePackages = "com.example.payment")
public class PaymentExceptionHandler {
@ExceptionHandler(PaymentFailedException.class)
public ResponseEntity<ErrorResponse> handlePaymentFailed(PaymentFailedException ex) {
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED)
.body(new ErrorResponse("PAYMENT_FAILED", ex.getMessage()));
}
@ExceptionHandler(InsufficientFundsException.class)
public ResponseEntity<ErrorResponse> handleInsufficientFunds(InsufficientFundsException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("INSUFFICIENT_FUNDS", ex.getMessage()));
}
}

This keeps exception handlers close to their domains. The payment team can work on PaymentExceptionHandler without worrying about user-related code.

Strategy 2: Annotation-Scoped Handlers

Create custom annotations to mark specific controller groups:

ApiExceptionHandler.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiExceptionHandler {
// Marker annotation for REST API exception handling
}
@RestControllerAdvice(annotations = ApiExceptionHandler.class)
public class ApiExceptionHandlerAdvice {
@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_ERROR", String.join(", ", errors)));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleMalformedJson(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("MALFORMED_REQUEST", "Invalid JSON format"));
}
}
UserController.java
@RestController
@ApiExceptionHandler
@RequestMapping("/api/users")
public class UserController {
// This controller uses ApiExceptionHandlerAdvice
}

This approach works well when you have different API versions or client types (web vs mobile vs internal).

Strategy 3: Controller-Specific Handlers

Use assignableTypes for fine-grained control:

OrderControllerExceptionHandler.java
@RestControllerAdvice(assignableTypes = {OrderController.class, OrderManagementController.class})
public class OrderControllerExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("ORDER_NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(InvalidOrderStateException.class)
public ResponseEntity<ErrorResponse> handleInvalidOrderState(InvalidOrderStateException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("INVALID_ORDER_STATE", ex.getMessage()));
}
}

This is useful when specific controllers need custom error handling logic that shouldn’t apply globally.

Strategy 4: Layered Exception Hierarchy

Combine global handlers with domain-specific ones using @Order:

GlobalExceptionHandler.java
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("RUNTIME_ERROR", ex.getMessage()));
}
}
UserExceptionHandler.java
@RestControllerAdvice(basePackages = "com.example.user")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class UserExceptionHandler {
@ExceptionHandler(UserDomainException.class)
public ResponseEntity<ErrorResponse> handleUserDomainException(UserDomainException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("USER_ERROR", ex.getMessage()));
}
}

Spring matches more specific handlers first. The @Order annotation ensures domain handlers take precedence over the global fallback.

Strategy 5: Exception Category Classes

Group exceptions by category rather than domain:

ValidationExceptionHandler.java
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
// Handle Bean Validation errors
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
// Handle JPA constraint violations
}
@ExceptionHandler(BindException.class)
public ResponseEntity<ErrorResponse> handleBindException(BindException ex) {
// Handle form binding errors
}
}
SecurityExceptionHandler.java
@RestControllerAdvice
public class SecurityExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
// Handle authorization failures
}
@ExceptionHandler(AuthenticationCredentialsNotFoundException.class)
public ResponseEntity<ErrorResponse> handleMissingCredentials(AuthenticationCredentialsNotFoundException ex) {
// Handle missing authentication
}
}
DataAccessExceptionHandler.java
@RestControllerAdvice
public class DataAccessExceptionHandler {
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException ex) {
// Handle database constraint violations
}
@ExceptionHandler(OptimisticLockingFailureException.class)
public ResponseEntity<ErrorResponse> handleOptimisticLocking(OptimisticLockingFailureException ex) {
// Handle concurrent modification
}
}

This approach is clean when your application doesn’t have strong domain boundaries or when you want consistent handling across domains for certain exception types.

When to Split Your @ControllerAdvice

+------------------------+----------------------------------------+
| Indicator | Threshold |
+------------------------+----------------------------------------+
| Method count | More than 10 @ExceptionHandler methods |
+------------------------+----------------------------------------+
| File size | Single file exceeds 200 lines |
+------------------------+----------------------------------------+
| Team ownership | Multiple developers editing same file |
+------------------------+----------------------------------------+
| Domain mixing | Handlers for unrelated domains |
+------------------------+----------------------------------------+

Here’s a decision matrix for choosing the right strategy:

+------------------------+------------------+----------------------+
| Scenario | Recommended | Why |
| | Strategy | |
+------------------------+------------------+----------------------+
| Strong domain | Package-scoped | Handlers live with |
| boundaries | | their domains |
+------------------------+------------------+----------------------+
| Multiple API versions | Annotation- | Version-specific |
| or client types | scoped | error handling |
+------------------------+------------------+----------------------+
| Specific controllers | Controller- | Fine-grained |
| need custom handling | specific | control |
+------------------------+------------------+----------------------+
| Need fallback | Layered | Domain handlers |
| handling | hierarchy | override global |
+------------------------+------------------+----------------------+
| Exceptions group | Exception | Consistent handling |
| by type | category classes | across domains |
+------------------------+------------------+----------------------+

A Practical Example

Let’s see how this works in a real application structure:

src/main/java/com/example/
├── user/
│ ├── controller/
│ │ └── UserController.java
│ ├── exception/
│ │ ├── UserNotFoundException.java
│ │ └── InvalidCredentialsException.java
│ └── UserExceptionHandler.java (basePackages = "com.example.user")
├── payment/
│ ├── controller/
│ │ └── PaymentController.java
│ ├── exception/
│ │ ├── PaymentFailedException.java
│ │ └── InsufficientFundsException.java
│ └── PaymentExceptionHandler.java (basePackages = "com.example.payment")
└── common/
├── exception/
│ └── ErrorResponse.java
└── GlobalExceptionHandler.java (fallback for unexpected errors)

Each module is self-contained. The user team owns UserExceptionHandler, the payment team owns PaymentExceptionHandler, and they never conflict.

Key Takeaways

  1. Don’t create a single god class for all exception handlers
  2. Use Spring’s scoping mechanisms to split handlers by domain, annotation, or controller
  3. Keep handlers close to their domains for better cohesion
  4. Apply @Order when using layered exception handling
  5. Monitor file size and method count as indicators for splitting

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