Skip to main content

Mastering Two-Tier Exception Handling in Spring Boot: A Complete Guide

Β· 11 min read
Mahmut Salman
Software Developer

Ever wondered why sometimes your Spring Boot exceptions are caught by @RestControllerAdvice and other times they're not? Or why your custom AccessDeniedHandler returns 403 before your controller even executes? I spent hours debugging this mystery until I understood Spring Boot's two-tier exception handling architecture. Let me save you the confusion. 🎯

The Confusion That Started It All​

Here's what happened: I had a perfectly working exception handler for AccessDeniedException in my @RestControllerAdvice:

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ErrorResponse(403, "Access denied!"));
}

But when I tested a restricted endpoint, this handler never got called. Instead, my CustomAccessDeniedHandler was returning the response. What's going on? πŸ€”

The revelation: Spring Boot has TWO separate exception handling layers, and understanding when each one kicks in is crucial.


The Big Picture: Two-Tier Architecture​

Our application uses a two-tier exception handling architecture:

  1. Filter Level (Spring Security) - Handles authorization failures before controllers
  2. Controller Level (Spring MVC) - Handles business logic exceptions during controller execution

Think of it like airport security:

  • Filter Level = TSA checkpoint (catches issues before you board)
  • Controller Level = In-flight safety (handles issues during the journey)

Visual Architecture Flow​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ HTTP REQUEST β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SPRING SECURITY FILTER CHAIN β”‚
β”‚ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ 1. JwtAuthenticationFilter β”‚ β”‚
β”‚ β”‚ - Extract JWT token from Authorization header β”‚ β”‚
β”‚ β”‚ - Validate token β”‚ β”‚
β”‚ β”‚ - Set authentication in SecurityContext β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ ↓ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ 2. Authorization Filter β”‚ β”‚
β”‚ β”‚ - Check @PreAuthorize annotations β”‚ β”‚
β”‚ β”‚ - Check SecurityConfig URL patterns β”‚ β”‚
β”‚ β”‚ - Validate user has required role/permissions β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ ↓ β”‚
β”‚ ❌ ACCESS DENIED? β”‚
β”‚ ↓ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ CustomAccessDeniedHandler (FILTER LEVEL) β”‚ β”‚
β”‚ β”‚ βœ… Returns: 403 with custom JSON message β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓ (Only if authorized)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SPRING MVC / CONTROLLER LAYER β”‚
β”‚ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Controller Method Executes β”‚ β”‚
β”‚ β”‚ - Business logic β”‚ β”‚
β”‚ β”‚ - Service calls β”‚ β”‚
β”‚ β”‚ - Data validation β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ ↓ β”‚
β”‚ ❌ EXCEPTION THROWN? β”‚
β”‚ ↓ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ GlobalExceptionHandler (CONTROLLER LEVEL) β”‚ β”‚
β”‚ β”‚ βœ… Returns: Custom status code with ErrorResponse β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ JSON RESPONSE β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Layer 1: Controller-Level Exception Handling​

The Familiar Territory: @RestControllerAdvice​

This is what most Spring Boot developers know and love. When you throw an exception in your controller or service, @RestControllerAdvice catches it and returns a nice error response.

Location: src/main/java/com/ecommerce/app/exception/GlobalExceptionHandler.java

Technology: @RestControllerAdvice + @ExceptionHandler

Purpose: Catches exceptions thrown during controller/service execution

Implementation​

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(InvalidInputException.class)
public ResponseEntity<ErrorResponse> handleInvalidInput(InvalidInputException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
ex.getMessage()
);
return ResponseEntity.badRequest().body(error);
}

@ExceptionHandler(UserAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleUserAlreadyExists(UserAlreadyExistsException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(), // 409 Conflict
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}

@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.UNAUTHORIZED.value(), // 401 Unauthorized
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.FORBIDDEN.value(), // 403 Forbidden
"Access denied. You do not have the required permissions."
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}

Exception Types Handled​

ExceptionHTTP StatusUse Case
InvalidInputException400 Bad RequestMissing or invalid input data
UserAlreadyExistsException409 ConflictEmail/username already exists
InvalidCredentialsException401 UnauthorizedWrong email/password
AccessDeniedException403 ForbiddenManual access denial in code
RuntimeException500 Internal Server ErrorCatch-all for unexpected errors

How It Works: Simple and Clean​

Controllers just throw exceptions - no need to handle them manually:

@PostMapping("/register")
public ResponseEntity<RegisterResponse> register(@RequestBody RegisterRequest request) {
// Validation - GlobalExceptionHandler catches this
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
throw new InvalidInputException("Username is required");
// ← Caught by GlobalExceptionHandler βœ…
}

// Business logic - GlobalExceptionHandler catches this too
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new UserAlreadyExistsException("Email already registered");
// ← Caught by GlobalExceptionHandler βœ…
}

// Success path
return ResponseEntity.ok(response);
}

Response Format​

All controller-level exceptions return the ErrorResponse DTO:

public class ErrorResponse {
private int status;
private String message;
private String timestamp;
}

Example Response:

{
"status": 400,
"message": "Username is required",
"timestamp": "2025-10-21T08:30:45.123Z"
}

Layer 2: Filter-Level Exception Handling​

The Hidden Layer: CustomAccessDeniedHandler​

This is where things get interesting. Filter-level exceptions occur BEFORE your controller even exists in the execution flow.

Location: src/main/java/com/ecommerce/app/security/CustomAccessDeniedHandler.java

Technology: AccessDeniedHandler interface (Spring Security)

Purpose: Catches Spring Security authorization failures that happen in the filter chain

The Key Insight πŸ’‘β€‹

When Spring Security denies access at the filter level:

  1. ❌ Your controller method never executes
  2. ❌ @RestControllerAdvice never sees the exception
  3. βœ… CustomAccessDeniedHandler handles it directly

Implementation​

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {

// Set response status to 403 Forbidden
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");

// Create custom error response
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", 403);
errorResponse.put("message", "Access denied. You do not have the required permissions to access this resource.");

// Write JSON response
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}

Security Configuration Integration​

The handler must be registered in SecurityConfig.java:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ... other configuration ...

// Configure exception handling for access denied (403)
.exceptionHandling(exception -> exception
.accessDeniedHandler(customAccessDeniedHandler)
);

return http.build();
}
}

When Does It Trigger?​

1. @PreAuthorize annotation failures

@PreAuthorize("hasRole('ADMIN')")  // ← USER tries to access
@DeleteMapping("/users/{id}")
public ResponseEntity<?> deleteUser() {
// ❌ NEVER REACHED - CustomAccessDeniedHandler returns 403
}

2. SecurityConfig URL pattern failures

// In SecurityConfig:
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// USER tries to access /api/admin/users
// ❌ CustomAccessDeniedHandler returns 403 before controller

Response Format​

Filter-level exceptions return raw JSON (NOT ErrorResponse DTO):

{
"status": 403,
"message": "Access denied. You do not have the required permissions to access this resource."
}

Why not use ErrorResponse DTO? Because we're outside Spring MVC context - we're in the filter chain where DTOs aren't available yet!


The Critical Comparison: When to Use Which Layer?​

AspectController-LevelFilter-Level
HandlerGlobalExceptionHandlerCustomAccessDeniedHandler
Technology@RestControllerAdviceAccessDeniedHandler interface
Execution Point⏰ After authorization, during controller⏰ Before controller, during security filters
CatchesExplicit throws in controller/serviceSpring Security authorization failures
PriorityπŸ₯ˆ Second line of defenseπŸ₯‡ First line of defense
Response FormatErrorResponse DTORaw JSON Map
Use CasesBusiness logic errors, validation@PreAuthorize failures, URL patterns
Controller Access?βœ… Yes - exception from controller❌ No - controller never executes

Real-World Scenarios: Understanding the Flow​

Scenario 1: Invalid User Input (Controller-Level)​

The Setup:

// AuthService.java
public RegisterResponse registerUser(RegisterRequest request) {
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
throw new InvalidInputException("Username is required");
}
// ...
}

// AuthController.java
@PostMapping("/register")
public ResponseEntity<RegisterResponse> register(@RequestBody RegisterRequest request) {
RegisterResponse response = authService.registerUser(request);
return ResponseEntity.ok(response);
}

The Request:

POST /api/auth/register
{
"username": "",
"email": "test@example.com",
"password": "password123"
}

The Response (via GlobalExceptionHandler):

{
"status": 400,
"message": "Username is required",
"timestamp": "2025-10-21T08:30:45.123Z"
}

The Flow:

  1. βœ… Request passes through security filters (public endpoint)
  2. βœ… Controller method executes
  3. βœ… Service called
  4. ❌ Service throws InvalidInputException
  5. βœ… Exception propagates to GlobalExceptionHandler
  6. βœ… Returns 400 Bad Request

Scenario 2: Email Already Exists (Controller-Level)​

The Setup:

// AuthService.java
public RegisterResponse registerUser(RegisterRequest request) {
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new UserAlreadyExistsException("Email already registered");
}
// ...
}

The Request:

POST /api/auth/register
{
"username": "john_doe",
"email": "existing@example.com",
"password": "password123"
}

The Response (via GlobalExceptionHandler):

{
"status": 409,
"message": "Email already registered",
"timestamp": "2025-10-21T08:30:45.123Z"
}

The Flow:

  1. βœ… Request passes security filters
  2. βœ… Controller executes
  3. βœ… Service checks database
  4. ❌ Email exists! Throws UserAlreadyExistsException
  5. βœ… GlobalExceptionHandler catches it
  6. βœ… Returns 409 Conflict

Scenario 3: @PreAuthorize Failure (Filter-Level) βš‘β€‹

This is where it gets interesting!

The Setup:

// UserController.java
@PreAuthorize("hasRole('USER')") // ← Requires USER role
@GetMapping("/user-only")
public ResponseEntity<Map<String, String>> userOnly() {
return ResponseEntity.ok(Map.of(
"message", "Welcome! This endpoint is for USERs only",
"role", "USER"
));
}

The Request (with ADMIN token):

GET /api/user/user-only
Authorization: Bearer <admin-jwt-token>

The Response (via CustomAccessDeniedHandler):

{
"status": 403,
"message": "Access denied. You do not have the required permissions to access this resource."
}

The Flow:

  1. βœ… Request enters Spring Security filters
  2. βœ… JwtAuthenticationFilter validates token - ADMIN token is valid
  3. ❌ Authorization filter checks @PreAuthorize("hasRole('USER')")
  4. ❌ ADMIN does not have USER role - ACCESS DENIED
  5. βœ… CustomAccessDeniedHandler returns 403
  6. ❌ Controller method NEVER EXECUTES

The Critical Point: Notice the controller never runs! This is filter-level security in action.


Scenario 4: URL Pattern Restriction (Filter-Level) βš‘β€‹

The Setup:

// SecurityConfig.java
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// AdminController.java
@DeleteMapping("/users/{id}") // ← /api/admin/users/{id}
public ResponseEntity<DeleteResponse> deleteUser(@PathVariable Long id) {
// This code will NEVER run if user lacks ADMIN role
}

The Request (with USER token):

DELETE /api/admin/users/2
Authorization: Bearer <user-jwt-token>

The Response (via CustomAccessDeniedHandler):

{
"status": 403,
"message": "Access denied. You do not have the required permissions to access this resource."
}

The Flow:

  1. βœ… Request enters filters
  2. βœ… JWT validated - USER token is valid
  3. ❌ SecurityConfig pattern check: /api/admin/** requires ADMIN
  4. ❌ User has USER role, not ADMIN - ACCESS DENIED
  5. βœ… CustomAccessDeniedHandler returns 403
  6. ❌ Controller NEVER REACHED

Scenario 5: User Not Found (Controller-Level)​

The Setup:

// AdminController.java
@DeleteMapping("/users/{id}")
public ResponseEntity<DeleteResponse> deleteUser(@PathVariable Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));

userRepository.deleteById(id);
return ResponseEntity.ok(response);
}

The Request (with ADMIN token):

DELETE /api/admin/users/999
Authorization: Bearer <admin-jwt-token>

The Response (via GlobalExceptionHandler):

{
"status": 500,
"message": "User not found with id: 999",
"timestamp": "2025-10-21T08:30:45.123Z"
}

The Flow:

  1. βœ… Passes security filters - ADMIN role authorized
  2. βœ… Controller method executes
  3. ❌ User lookup fails
  4. ❌ Throws RuntimeException
  5. βœ… GlobalExceptionHandler catches it
  6. βœ… Returns 500 Internal Server Error

Scenario 6: Invalid Login Credentials (Controller-Level)​

The Setup:

// AuthService.java
public LoginResponse loginUser(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new InvalidCredentialsException("Invalid email or password"));

if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new InvalidCredentialsException("Invalid email or password");
}
// ...
}

The Request:

POST /api/auth/login
{
"email": "user@example.com",
"password": "wrongpassword"
}

The Response (via GlobalExceptionHandler):

{
"status": 401,
"message": "Invalid email or password",
"timestamp": "2025-10-21T08:30:45.123Z"
}

The Flow:

  1. βœ… Passes filters (login is public)
  2. βœ… Controller executes
  3. βœ… Service finds user
  4. ❌ Password doesn't match
  5. ❌ Throws InvalidCredentialsException
  6. βœ… GlobalExceptionHandler catches it
  7. βœ… Returns 401 Unauthorized

Best Practices: Choosing the Right Layer​

βœ… Use Filter-Level (CustomAccessDeniedHandler) For:​

  • Spring Security authorization failures
  • @PreAuthorize / @PostAuthorize violations
  • SecurityConfig URL pattern enforcement
  • Anything that prevents controller execution

βœ… Use Controller-Level (GlobalExceptionHandler) For:​

  • Business logic errors
  • Data validation failures
  • Database constraint violations
  • Custom application exceptions
  • Anything thrown during controller execution

Exception Design: Good vs Bad​

βœ… Good: Specific and Descriptive​

throw new UserAlreadyExistsException("Email already registered: " + email);

❌ Bad: Generic and Unhelpful​

throw new RuntimeException("Error");

Consistent Response Formats​

Controller-Level: ErrorResponse DTO​

public class ErrorResponse {
private int status;
private String message;
private String timestamp;

// Constructors, getters, setters
}

Benefits:

  • βœ… Consistent structure
  • βœ… Easy to parse on frontend
  • βœ… Extensible (add fields like path, errors[], etc.)

Filter-Level: Raw JSON​

Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", 403);
errorResponse.put("message", "Access denied...");

Why raw JSON?

  • We're outside Spring MVC context
  • DTOs not available in filter chain
  • Must manually construct with ObjectMapper

Security Considerations​

❌ Never Expose:​

  • Stack traces in production
  • Internal database errors
  • Sensitive system information
  • Implementation details

βœ… Always:​

  • Log detailed errors server-side
  • Return generic messages to client
  • Use appropriate HTTP status codes
  • Sanitize error messages

Example:

// ❌ BAD - Exposes internals
throw new RuntimeException("SQLException: Duplicate entry 'test@test.com' for key 'users.email'");

// βœ… GOOD - Generic but helpful
throw new UserAlreadyExistsException("Email already registered");

Testing Both Layers​

Testing Controller-Level Exceptions​

@Test
void shouldReturn400WhenUsernameIsEmpty() {
RegisterRequest request = new RegisterRequest();
request.setUsername(""); // Invalid

mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Username is required"));
}

Testing Filter-Level Exceptions​

@Test
void shouldReturn403WhenUserTriesToAccessAdminEndpoint() {
String userToken = generateUserToken();

mockMvc.perform(delete("/api/admin/users/1")
.header("Authorization", "Bearer " + userToken))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.message")
.value("Access denied. You do not have the required permissions to access this resource."));
}

Quick Reference Cheat Sheet​

When GlobalExceptionHandler Catches It:​

βœ… throw new InvalidInputException()
βœ… throw new UserAlreadyExistsException()
βœ… throw new InvalidCredentialsException()
βœ… throw new RuntimeException()
βœ… Any exception thrown in controller/service

When CustomAccessDeniedHandler Catches It:​

βœ… @PreAuthorize("hasRole('ADMIN')") - user lacks role
βœ… SecurityConfig: .requestMatchers("/admin/**").hasRole("ADMIN")
βœ… Spring Security authorization failures
βœ… Before controller execution

Decision Tree:​

Did the exception occur BEFORE the controller executed?
β”‚
β”œβ”€ YES β†’ Filter-Level (CustomAccessDeniedHandler)
β”‚ Examples: @PreAuthorize, URL patterns
β”‚
└─ NO β†’ Controller-Level (GlobalExceptionHandler)
Examples: Business logic, validation, database errors

Summary: The Power of Two-Tier Architecture​

Our exception handling system provides:

βœ… Comprehensive Coverage - Errors caught at both filter and controller levels βœ… Consistent Responses - Uniform JSON format across all error types βœ… Defense in Depth - Multiple layers of error handling βœ… Clear Separation - Security errors vs. business logic errors βœ… Developer Friendly - Just throw exceptions, framework handles the rest βœ… Production Ready - Secure, informative error messages


The Key Takeaway πŸŽ―β€‹

Controllers focus on business logic and throw exceptions - the framework handles the rest automatically through our two-tier exception handling system:

  1. Filter Level catches security/authorization issues before controllers
  2. Controller Level catches business logic issues during execution

Understanding this separation is the key to debugging authentication and authorization issues in Spring Boot applications.


What's Next?​

Now that you understand the two-tier architecture, you might want to explore:

  • Custom exception types for specific business domains
  • Internationalization (i18n) for error messages
  • Structured logging for exception tracking
  • Monitoring and alerting for production errors
  • Request/response logging in filters

Have questions or suggestions? Drop them in the comments below! πŸ‘‡

Found this helpful? Share it with your fellow Spring Boot developers! πŸš€