Skip to content

Spring Boot Exception Handling | @ControllerAdvice, @ResponseStatus, @ExceptionHandler

The Central Hospital Triage Analogy

Imagine a busy metropolitan emergency room. When ambulances arrive with injured patients, the doctors in surgery do not leave the operating theater to answer phone calls at the front desk, fill out insurance billing paperwork, and console family members in the waiting room.

Instead, the hospital operates a specialized Triage and Admissions Unit. When an emergency occurs anywhere in the hospital, the triage team intercepts the situation: they assess the severity, map the injury to the correct surgical specialist, generate standardized medical charts, and deliver clear status updates to families. The surgeons focus entirely on medicine, while the triage unit handles standardized error communication.

In Spring Boot, @ControllerAdvice and @ExceptionHandler are that central hospital triage unit. Without global exception handling, every controller method becomes bloated with repetitive try/catch blocks, inconsistent error payloads, and scattered error codes. Global exception handling intercepts exceptions thrown from any controller in your application, formats them into a clean, standardized JSON response conforming to RFC 7807, and maps business exceptions to appropriate HTTP status codes.

This lecture covers traditional versus global exception handling, @ExceptionHandler, @ControllerAdvice vs @RestControllerAdvice, @ResponseStatus, and building a production grade error response pipeline.


The Problem with Local Try Catch in Controllers

Consider what happens when exception handling is handled manually inside individual controllers:

java
// ANTI-PATTERN: Bloated, repetitive, inconsistent error handling
@RestController
@RequestMapping("/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<?> getUser(@PathVariable Long id) {
        try {
            User user = userService.findById(id);
            return ResponseEntity.ok(user);
        } catch (UserNotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User missing: " + e.getMessage());
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid argument");
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Something broke");
        }
    }
}

Why This Fails in Production:

  1. Severe Code Duplication: If you have thirty controller methods, you must duplicate these identical try/catch blocks thirty times.
  2. Inconsistent API Contract: One method returns a raw string, another returns a map, another returns an object. Frontend clients cannot reliably parse error messages.
  3. Obscured Business Logic: The actual one line business call (userService.findById(id)) is buried under fifteen lines of defensive boilerplate.

Global Exception Handling Architecture

Spring Web provides an interception pipeline using AOP (Aspect Oriented Programming) under the hood:

[ Client Request ]
       |
       v
[ Controller Method ] ---> Throws UserNotFoundException!
       |
       | (Exception propagates up)
       v
+-----------------------------------------------------------+
|               @RestControllerAdvice                       |
|  - Intercepts UserNotFoundException                       |
|  - Catches exception in @ExceptionHandler method          |
|  - Builds standardized ApiErrorResponse JSON              |
|  - Sets HTTP Status to 404 Not Found                      |
+-----------------------------------------------------------+
       |
       v
[ Formatted JSON Response (404) returned to Client ]

Now, your controller method shrinks to its pure happy path:

java
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    // If user is not found, service throws UserNotFoundException
    // Controller does NOT catch it! Global advice catches it automatically!
    return ResponseEntity.ok(userService.findById(id));
}

Core Annotations Explained

1. @ControllerAdvice vs @RestControllerAdvice

  • @ControllerAdvice: Declares a global interceptor for controllers. Exception handler methods inside it default to returning view template names unless annotated with @ResponseBody.
  • @RestControllerAdvice: A meta annotation combining @ControllerAdvice and @ResponseBody. All exception handler methods automatically serialize their return values directly into the HTTP response body as JSON.

2. @ExceptionHandler

Placed on methods inside an advice class to declare which specific exception class or classes that method handles:

java
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleUserNotFound(UserNotFoundException ex) {
    // handles UserNotFoundException
}

You can also handle multiple exceptions in a single method:

java
@ExceptionHandler({ UserNotFoundException.class, OrderNotFoundException.class })
public ResponseEntity<ApiErrorResponse> handleNotFound(RuntimeException ex) {
    // handles both
}

3. @ResponseStatus

Can be placed on custom exception classes to declare their default HTTP return code:

java
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Building a Production Grade Global Exception Handler

1. The Standard Error Response DTO (RFC 7807)

java
package com.example.orderservice.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
import java.util.List;

public class ApiErrorResponse {

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime timestamp;

    private int status;
    private String error;
    private String message;
    private String path;
    private List<String> validationErrors; // Optional field for validation failures

    public ApiErrorResponse(int status, String error, String message, String path) {
        this.timestamp = LocalDateTime.now();
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
    }

    // Getters and setters
    public LocalDateTime getTimestamp() { return timestamp; }
    public int getStatus() { return status; }
    public String getError() { return error; }
    public String getMessage() { return message; }
    public String getPath() { return path; }
    public List<String> getValidationErrors() { return validationErrors; }
    public void setValidationErrors(List<String> validationErrors) { this.validationErrors = validationErrors; }
}

2. The Custom Domain Exception

java
package com.example.orderservice.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

3. The Global Controller Advice Implementation

java
package com.example.orderservice.exception;

import com.example.orderservice.dto.ApiErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 1. Handle Custom Resource Not Found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request) {

        ApiErrorResponse error = new ApiErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            HttpStatus.NOT_FOUND.getReasonPhrase(),
            ex.getMessage(),
            request.getRequestURI()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // 2. Handle Spring Validation Failures (@Valid @RequestBody)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiErrorResponse> handleValidationExceptions(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        List<String> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
            .map(field -> field.getField() + ": " + field.getDefaultMessage())
            .collect(Collectors.toList());

        ApiErrorResponse error = new ApiErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            "Validation Failed",
            "Input validation failed for " + fieldErrors.size() + " fields",
            request.getRequestURI()
        );
        error.setValidationErrors(fieldErrors);

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    // 3. Fallback Handler for All Other Unchecked Exceptions
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiErrorResponse> handleGenericException(
            Exception ex,
            HttpServletRequest request) {

        // Log unexpected error internally with stack trace
        System.err.println("[INTERNAL ERROR] Unexpected failure: " + ex.getMessage());

        ApiErrorResponse error = new ApiErrorResponse(
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
            "An unexpected internal error occurred. Please contact support.",
            request.getRequestURI()
        );

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

Sample JSON Responses Produced

When Resource Not Found Occurs:

json
{
  "timestamp": "2026-09-05 15:45:20",
  "status": 404,
  "error": "Not Found",
  "message": "User with id 99 not found",
  "path": "/api/users/99"
}

When Validation Fails (400 Bad Request):

json
{
  "timestamp": "2026-09-05 15:45:22",
  "status": 400,
  "error": "Validation Failed",
  "message": "Input validation failed for 2 fields",
  "path": "/api/users",
  "validationErrors": [
    "email: must be a well-formed email address",
    "password: size must be between 8 and 32"
  ]
}

Interview Questions & Pitfalls

Q1: What is the difference between @ControllerAdvice and @RestControllerAdvice?

@ControllerAdvice is the generic advice annotation used in traditional MVC web applications where handler methods return view template names. @RestControllerAdvice is a meta annotation that combines @ControllerAdvice and @ResponseBody, ensuring that all return values from @ExceptionHandler methods are automatically serialized into the HTTP response body as JSON or XML.

Q2: How does Spring resolve which @ExceptionHandler to execute when an exception is thrown?

Spring uses exception hierarchy depth matching. It selects the handler registered for the most specific exception class in the hierarchy. For example, if both ResourceNotFoundException and generic Exception handlers are registered, throwing ResourceNotFoundException routes strictly to the specific handler, not the generic fallback.

Q3: How do you handle input validation errors triggered by @Valid on request bodies?

When @Valid fails on a @RequestBody parameter, Spring throws MethodArgumentNotValidException. In your @RestControllerAdvice class, create an @ExceptionHandler(MethodArgumentNotValidException.class) method, extract field validation errors from ex.getBindingResult().getFieldErrors(), and return a 400 Bad Request response with field level messages.

Q4: Can @ExceptionHandler be used directly inside an individual controller without @ControllerAdvice?

Yes. Placing @ExceptionHandler inside a specific controller class intercepts exceptions thrown only by handler methods in that controller. @ControllerAdvice is used when you want the exception handler to apply globally across all controllers in the application.

Q5: Why should you avoid exposing raw exception messages in the generic Exception.class handler?

Exposing raw exception messages or stack traces from generic 500 Internal Server Error exceptions to API clients creates severe security risks: it leaks internal database names, SQL queries, table structures, and internal package paths to potential attackers. Always log the raw exception internally and return a sanitized, generic error message to the client.