Exception Handling with @ControllerAdvice

Reviewed & published by Brayan K

@ControllerAdvice centralizes error handling across every controller. With @ExceptionHandler methods returning ResponseEntity error bodies — or a quick ResponseStatusException — your API speaks in clean, consistent status codes.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

Before You Start

You should understand Java exceptions and how Spring REST controllers return responses. The validation lesson pairs well with this one.

What You'll Learn

The Big Idea — One Help Desk for the Whole Building

💡 Analogy: Without central handling, every office (controller) keeps its own first-aid kit and gives different answers when something goes wrong. @ControllerAdvice is a single help desk for the whole building: whenever any office raises a problem, it routes to the right specialist (@ExceptionHandler) who responds with a consistent, well-formatted answer — the right status code and a tidy error body — every time.

Controllers stay focused on the happy path; the advice handles the unhappy ones.

1️⃣ A Central Exception Handler

Annotate a class with @RestControllerAdvice and add an @ExceptionHandler method per error type. Each returns a ResponseEntity with the right status and a structured body. A catch-all prevents raw stack traces from leaking.

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import java.time.Instant;
import java.util.*;

// One place that handles exceptions for ALL controllers and returns JSON.
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handle a domain "not found" -> 404 with a structured body.
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorBody(ex.getMessage(), 404));
    }

    // Handle bean-validation failures -> 400 with field messages.
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleInvalid(MethodArgumentNotValidException ex) {
        Map<String, Object> body = errorBody("validation failed", 400);
        Map<String, String> fields = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors()
          .forEach(fe -> fields.put(fe.getField(), fe.getDefaultMessage()));
        body.put("fields", fields);
        return ResponseEntity.badRequest().body(body);
    }

    // Catch-all -> 500 so no raw stack trace leaks to clients.
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleOther(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(errorBody("unexpected error", 500));
    }

    private Map<String, Object> errorBody(String message, int status) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", Instant.now().toString());
        body.put("status", status);
        body.put("message", message);
        return body;
    }
}

2️⃣ Raising Errors from a Controller

Throw a custom domain exception (handled by the advice) for reusable, meaningful errors, or a ResponseStatusException inline for quick one-offs. Both end up as the correct HTTP status.

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/customers")
public class CustomerController {

    private final CustomerRepository repo;
    public CustomerController(CustomerRepository repo) { this.repo = repo; }

    @GetMapping("/{id}")
    public Customer one(@PathVariable Long id) {
        // Option A: throw a custom exception (handled by the advice -> 404).
        return repo.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("customer " + id + " not found"));
    }

    @GetMapping("/{id}/quick")
    public Customer oneQuick(@PathVariable Long id) {
        // Option B: throw ResponseStatusException directly with the status.
        return repo.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "not found"));
    }
}

// A simple domain exception the advice maps to 404.
class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) { super(message); }
}

3️⃣ How the Right Handler Is Chosen

Spring picks the handler whose exception type best matches what was thrown, falling back to a catch-all. Here is that "match by type" dispatch in plain Java you can run.

import java.util.*;
import java.util.function.*;

// PLAIN-JAVA illustration of "pick the handler by exception type",
// which is what @ControllerAdvice does behind the scenes.
public class Main {

    static class NotFound extends RuntimeException { NotFound(String m){ super(m);} }
    static class BadInput extends RuntimeException { BadInput(String m){ super(m);} }

    // map each exception type to an (httpStatus, message)
    static String handle(RuntimeException ex) {
        if (ex instanceof NotFound)  return "404: " + ex.getMessage();
        if (ex instanceof BadInput)  return "400: " + ex.getMessage();
        return "500: unexpected error";          // catch-all
    }

    public static void main(String[] args) {
        System.out.println(handle(new NotFound("customer 7 not found")));
        System.out.println(handle(new BadInput("name is required")));
        System.out.println(handle(new RuntimeException("boom")));
    }
}

🧠 Quick Recall

Predict before you peek.

Answer: the more specific ResourceNotFoundException handler; Exception is the fallback.

Answer: 404 Not Found.

Answer: @ResponseBody — return values are serialized as the JSON response body.

🎯 YOUR TURN — Map a 409 Conflict

Add an @ExceptionHandler for a DuplicateEmailException that returns 409 Conflict.

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

// YOUR TURN: add a handler for a custom DuplicateEmailException.
//  - it should map to 409 Conflict
//  - return a ResponseEntity with a small body { "message": ex.getMessage() }
//
// @RestControllerAdvice
// public class GlobalExceptionHandler {
//     @ExceptionHandler(DuplicateEmailException.class)
//     public ResponseEntity<?> handleDup(DuplicateEmailException ex) {
//         // return 409 Conflict with the message
//     }
// }

🧩 MINI-CHALLENGE — One handler, two exceptions

Handle both IllegalArgumentException and IllegalStateException with a single handler returning 400.

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

// MINI-CHALLENGE: one handler for MULTIPLE exception types.
//  - Handle BOTH IllegalArgumentException and IllegalStateException
//    with a single @ExceptionHandler({...}) method -> 400 Bad Request.
//  - Include the exception's simple class name and message in the body.
//
// Think: when is sharing one handler better than two separate methods?
//        which status fits "the client sent something invalid"?

Common Errors

Pro Tips

📋 Quick Reference

ElementPurposeNote
@ControllerAdvicecross-cutting adviceSpans all controllers
@RestControllerAdviceadvice + @ResponseBodyJSON bodies
@ExceptionHandlermap type to handlerMost specific wins
ResponseEntitystatus + bodyExplicit control
ResponseStatusExceptioninline status throwQuick one-offs
400Bad RequestInvalid input
404Not FoundMissing resource
409ConflictDuplicate/state clash
500Internal Server ErrorUnexpected failure

📚 Resources / Keep Learning

🎉 Lesson Complete!

You can now centralize error handling with @ControllerAdvice, map exception types with @ExceptionHandler, return precise ResponseEntity error bodies, throw ResponseStatusException inline, and pick correct HTTP status codes.

Practice quiz

What does a class annotated with @ControllerAdvice provide?

  • Cross-cutting exception handling shared across controllers
  • A new controller endpoint
  • A database connection
  • A scheduled task

Answer: Cross-cutting exception handling shared across controllers. @ControllerAdvice centralizes exception handling (and other advice) across all controllers in one place.

What does @ExceptionHandler(SomeException.class) do?

  • Throws the exception
  • Maps that exception type to a handler method
  • Logs only
  • Disables the exception

Answer: Maps that exception type to a handler method. @ExceptionHandler binds a method to handle the listed exception type(s) when thrown by a controller.

What is the benefit of returning ResponseEntity from a handler?

  • It hides the body
  • It is required by Java
  • It caches the response
  • You control both the HTTP status and the response body

Answer: You control both the HTTP status and the response body. ResponseEntity lets you set the status code, headers, and body explicitly for the error response.

What does throwing a ResponseStatusException(HttpStatus.NOT_FOUND) result in?

  • A 200 response
  • A 404 response with that status
  • A compile error
  • A redirect

Answer: A 404 response with that status. ResponseStatusException maps directly to the given HTTP status, here 404 Not Found.

Which combination makes a @RestController-style advice that returns JSON bodies?

  • @Controller only
  • @ControllerAdvice + @ResponseBody, or @RestControllerAdvice
  • @Service
  • @Component only

Answer: @ControllerAdvice + @ResponseBody, or @RestControllerAdvice. @RestControllerAdvice = @ControllerAdvice + @ResponseBody, so handler return values become JSON bodies.

If two @ExceptionHandler methods could match, which is chosen?

  • A random one
  • The one for the most specific exception type
  • The first declared
  • Both run

Answer: The one for the most specific exception type. Spring picks the handler whose declared exception type is the most specific match for the thrown exception.

What status is conventional for a failed bean validation on a request body?

  • 201 Created
  • 403 Forbidden
  • 400 Bad Request
  • 500 Internal Server Error

Answer: 400 Bad Request. Invalid input is a client error, conventionally returned as 400 Bad Request.

Why centralize error handling in @ControllerAdvice instead of try/catch everywhere?

  • It runs faster
  • It avoids duplicated handling and gives consistent error responses
  • It removes exceptions
  • It is mandatory

Answer: It avoids duplicated handling and gives consistent error responses. Centralizing avoids repeating try/catch in every controller and yields uniform, consistent error responses.

What HTTP status best fits an unexpected server-side failure?

  • 400
  • 404
  • 500 Internal Server Error
  • 204

Answer: 500 Internal Server Error. Unhandled or unexpected server errors map to 500 Internal Server Error.

Can a single @ExceptionHandler handle multiple exception types?

  • No, only one
  • Only via inheritance
  • Only with reflection
  • Yes, by listing several classes in the annotation

Answer: Yes, by listing several classes in the annotation. You can pass an array of exception classes to @ExceptionHandler to handle several types with one method.

Continue this course

Frequently asked questions

What is @ControllerAdvice and why use it?

@ControllerAdvice is a specialization of @Component that lets you write cross-cutting logic — most commonly exception handling — that applies to all your controllers from one central class. Instead of wrapping every controller method in try/catch and producing slightly different error responses each time, you declare @ExceptionHandler methods once in the advice class and Spring routes matching exceptions there. The result is consistent, DRY error handling and controllers that stay focused on the happy path.

How does @ExceptionHandler choose which method handles an exception?

Each @ExceptionHandler declares one or more exception types it handles. When an exception propagates out of a controller, Spring finds the handler whose declared type most specifically matches the thrown exception, considering the class hierarchy. So a handler for ResourceNotFoundException is preferred over a broad handler for Exception when that specific exception is thrown. A catch-all @ExceptionHandler(Exception.class) acts as a fallback for anything not matched more specifically.

When should I throw ResponseStatusException instead of a custom exception?

ResponseStatusException is convenient for one-off cases where you just need to signal an HTTP status and message inline, without defining a new class — for example throwing new ResponseStatusException(HttpStatus.NOT_FOUND, "not found") right where you detect the problem. A custom exception plus an @ExceptionHandler is better when the error is a meaningful domain concept you raise in several places and want to format consistently. Many apps use both: custom exceptions for domain errors, ResponseStatusException for quick ad hoc cases.

Why return ResponseEntity from a handler rather than just a body?

ResponseEntity gives you explicit control over the full HTTP response: the status code, the headers, and the body. An error handler needs to set an appropriate status — 404 for not found, 400 for bad input, 500 for unexpected failures — and a structured body the client can parse. Returning a plain object would use a default status (often 200), which is wrong for errors. ResponseEntity.status(...).body(...) makes the status and payload deliberate and correct.

What is the difference between @ControllerAdvice and @RestControllerAdvice?

@RestControllerAdvice is simply @ControllerAdvice combined with @ResponseBody. With plain @ControllerAdvice, handler return values are treated like MVC view results unless you also add @ResponseBody, whereas @RestControllerAdvice automatically serializes return values (like a Map or ResponseEntity body) into the response body as JSON. For REST APIs returning JSON error payloads, @RestControllerAdvice is the convenient choice.

Related lessons