Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 60

Global Exception Handling with @ControllerAdvice

Recommended resources

Hello! In our last lesson, we took a big step toward making our API professional by implementing server-side validation. You learned how to use @Valid and how to catch the MethodArgumentNotValidException to return clean error messages. That was an excellent, specific example of exception handling.

Today, we will zoom out and generalize this powerful concept. What happens if a client requests an employee with an ID that doesn't exist? Or if the database connection fails unexpectedly? Handling these errors inside every single controller method leads to messy, repetitive code. This lesson is about solving that problem elegantly. You will learn how to use @ControllerAdvice to create a single, centralized place to manage all application exceptions, ensuring your API is not just functional, but also robust, maintainable, and predictable—a hallmark of professional backend development.

The Problem: Scattered Error Handling

Imagine our EmployeeService's getEmployeeById method. If no employee is found, the findById method from JpaRepository returns an empty Optional. A common approach is to throw an exception if the Optional is empty.

// In EmployeeService.java
public Employee getEmployeeById(Long id) {
    return employeeRepository.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("Employee not found with id: " + id));
}

(Note: ResourceNotFoundException would be a custom exception you create, which we'll discuss shortly.)

Now, how would you handle this in the controller? The most straightforward way is a try-catch block.

// In EmployeeController.java - The Naive Approach
@GetMapping("/{id}")
public ResponseEntity<?> getEmployeeById(@PathVariable Long id) {
    try {
        Employee employee = employeeService.getEmployeeById(id);
        return ResponseEntity.ok(employee);
    } catch (ResourceNotFoundException ex) {
        // Create an error response object
        ErrorDetails errorDetails = new ErrorDetails(new Date(), ex.getMessage(), "details...");
        return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND);
    }
}

This works, but it has major drawbacks that become painful in any real-world application:

  • Code Duplication: You would need to repeat this try-catch logic in your updateEmployee and deleteEmployee methods, and in any other controller that might deal with a resource that can't be found.
  • Maintenance Nightmare: If you decide to change the structure of your JSON error response, you have to find and update every single catch block in your entire application.
  • Inconsistency: Without a central standard, different parts of the API might return slightly different error formats, creating a confusing experience for the client.

This is where Spring's elegant exception handling mechanism comes into play. The video from CodeSnippet provides a fantastic walkthrough of this exact problem and its solution.

Master Exception Handling in Spring Boot: @ExceptionHandler & @ControllerAdvice Explained

This video masterfully illustrates the evolution from a messy, traditional try-catch approach to a clean, centralized one.

The Traditional Way: First, watch the segment demonstrating the traditional try-catch approach. The presenter shows how to catch an exception and return a custom ResponseEntity, making the error message more meaningful than the default "500 Internal Server Error". The Problem with Tradition: Next, pay close attention to the section explaining the drawbacks of this approach. This directly addresses the code duplication and maintenance challenges we just discussed.


Centralizing Exception Logic with @ControllerAdvice

The solution to this problem is to AOP (Aspect-Oriented Programming) to our advantage. We want to tell Spring: "Whenever any controller throws a ResourceNotFoundException, I want you to intercept it and run this specific piece of code to generate a response."

This is precisely what @ControllerAdvice does. It allows you to create a global handler class that is not tied to any single controller.

Let's see how this works.

Master Exception Handling in Spring Boot: @ExceptionHandler & @ControllerAdvice Explained

The same video continues to show the step-by-step solution.

Controller-Scoped Handler: The first step toward centralization is the @ExceptionHandler annotation. Watch the segment on implementing an exception handler within a controller. This is better, but as you'll see, it still has limitations. The Limitation: The video then perfectly illustrates the limitation by adding another controller. The exception handler from the first controller doesn't apply to the new one, forcing code duplication. This is the crucial "aha!" moment that justifies a global approach. The Global Solution: Finally, watch the introduction of @ControllerAdvice. In this section, the presenter creates a global exception handler class, moves the handler method into it, and annotates the class. This centralizes the logic, making it apply to all controllers in the application.


The Anatomy of a Global Exception Handler

So, let's put all the pieces together and build our own professional GlobalExceptionHandler.

1. Create a Custom Exception (Good Practice)

While you can catch built-in exceptions, it's a very common and powerful pattern to create your own semantic exceptions. This makes your code more readable.

// Create this in a new 'exceptions' package
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(value = HttpStatus.NOT_FOUND) // This conveniently sets the response status
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

2. Create a Structured Error Response DTO

Just like we used a DTO for incoming data, it's best practice to use a DTO for outgoing error responses. This gives you full control over the JSON structure.

// You can create this class inside your 'exceptions' package or a 'dto' package
import java.time.ZonedDateTime;

public class ApiExceptionDto {
    private final String message;
    private final int httpStatus;
    private final ZonedDateTime timestamp;

    // Constructor, Getters...
    public ApiExceptionDto(String message, int httpStatus, ZonedDateTime timestamp) {
        this.message = message;
        this.httpStatus = httpStatus;
        this.timestamp = timestamp;
    }
    
    public String getMessage() { return message; }
    public int getHttpStatus() { return httpStatus; }
    public ZonedDateTime getTimestamp() { return timestamp; }
}

The Amigoscode video offers a great explanation of creating custom exception and response classes.

Spring Boot Tutorial | How To Handle Exceptions

This video from Amigoscode complements the previous one by focusing on creating these custom classes, which is a key part of a professional setup.

Custom Exception Class: Watch how a custom exception is created by extending RuntimeException. Error Payload Class: See how a dedicated DTO is created to structure the error payload, including fields for the message, status, and timestamp.

3. Create the GlobalExceptionHandler

Now for the main event. We create a class annotated with @RestControllerAdvice. This annotation is a combination of @ControllerAdvice and @ResponseBody, making it perfect for REST APIs because it ensures the return value of your handler methods is automatically serialized into the response body.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handler for our specific "Not Found" custom exception
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiExceptionDto> handleResourceNotFound(ResourceNotFoundException ex) {
        ApiExceptionDto errorResponse = new ApiExceptionDto(
            ex.getMessage(),
            HttpStatus.NOT_FOUND.value(),
            ZonedDateTime.now()
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }

    // Handler for validation errors (from the last lesson)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage()));
        return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
    }

    // A catch-all handler for any other unhandled exception
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiExceptionDto> handleGenericException(Exception ex) {
        ApiExceptionDto errorResponse = new ApiExceptionDto(
            "An unexpected error occurred. Please try again later.",
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            ZonedDateTime.now()
        );
        // It's also a good idea to log the original exception here
        // log.error("Unhandled exception: ", ex);
        return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

Notice a few key things in this complete example:

  • We have multiple @ExceptionHandler methods, one for each type of exception we want to handle specifically.
  • Spring is smart enough to find the most specific handler. If a ResourceNotFoundException is thrown, it will use handleResourceNotFound, not the generic handleGenericException.
  • The handler for MethodArgumentNotValidException is the same logic from our last lesson, but now it lives in its proper, centralized home.
  • The final catch-all handler for Exception.class is crucial. It acts as a safety net, ensuring that no matter what goes wrong, your API will never crash and leak a raw stack trace to the client. It will always return a clean, predictable 500 error response.

Conclusion

You have now mastered one of the most important patterns for building professional, resilient REST APIs. By centralizing your exception handling, you make your code cleaner, easier to maintain, and provide a consistent, reliable experience for the developers who will use your API.

Key Takeaways:

  • Avoid try-catch blocks in controllers for handling standard application errors.
  • Use @ControllerAdvice (or @RestControllerAdvice for REST APIs) to create a global exception handling class.
  • Inside this class, define methods annotated with @ExceptionHandler(ExceptionType.class) to handle specific exceptions.
  • It is best practice to create your own custom exception classes (e.g., ResourceNotFoundException) and custom DTOs for error responses.
  • Always include a catch-all handler for Exception.class as a safety net to prevent exposing internal details and to provide a generic error message for unexpected issues.

With robust validation and now global exception handling in place, our API is becoming truly solid. In the next lesson, we will tackle the next critical pillar of backend development: security. You'll learn how to implement stateless authentication using JSON Web Tokens (JWT) to protect your endpoints and ensure that only authorized users can access and modify data.