Course topics

By WebNest Studio Engineering Team

Spring Boot Tutorial

Global Exception Handling in Spring Boot

Scattering try-catch blocks across every controller method produces inconsistent error responses and duplicated logic. Spring provides a centralized way to handle exceptions for an entire application: @ControllerAdvice combined with @ExceptionHandler methods, which intercept exceptions thrown anywhere in the request-handling flow and convert them into a single, consistent error response format.

@ControllerAdvice and @ExceptionHandler

A class annotated with @ControllerAdvice (or @RestControllerAdvice, which adds @ResponseBody automatically) applies across all controllers in the application. Inside it, each method annotated with @ExceptionHandler(SomeException.class) handles that specific exception type whenever it propagates out of any controller, letting you build error handling once instead of per-endpoint.

Mapping Exceptions to HTTP Status Codes

Different failure types should map to different HTTP status codes — a "resource not found" condition should return 404, a validation failure 400, an authorization failure 403, and an unexpected server error 500. Using @ResponseStatus on a custom exception class, or explicitly building a ResponseEntity inside the handler method, lets you control this precisely rather than letting every exception fall through to a generic 500.

Designing a Consistent Error Body

Client applications benefit enormously from every error response sharing the same JSON shape — typically a timestamp, status code, an error message, and the request path. Defining one small ErrorResponse record and using it in every @ExceptionHandler keeps error handling predictable across the whole API.

ProblemDetail: the Standard Error Format

Instead of inventing your own error record, you can use ProblemDetail, Spring's implementation of RFC 9457 ("Problem Details for HTTP APIs"). It has standard fields (type, title, status, detail, instance) and accepts extra properties such as a product ID. Many HTTP clients and API gateways already understand it. Throw an exception that extends ErrorResponseException and it carries its own status and ProblemDetail, so it needs no handler method at all. Setting spring.mvc.problemdetails.enabled=true makes Spring MVC's built-in errors (unknown URL, wrong parameter type and so on) use the same format.

The Catch-All Trap

A handler for Exception.class looks like a safe net, but on its own it catches Spring MVC's own exceptions too. An unknown URL raises NoResourceFoundException and a path variable like /api/stock/abc raises a type-mismatch exception. Both normally become 404 and 400, but a plain catch-all turns them into 500 "Unexpected error". The fix is to extend ResponseEntityExceptionHandler: the base class maps every standard Spring MVC exception to its correct status first, and only genuinely unexpected errors reach your catch-all method. Log those with the full stack trace, and return a generic message so internal details never reach the client.

Choosing the Status Code

  • 400 Bad Request: the request is malformed or fails validation.
  • 401 Unauthorized / 403 Forbidden: not logged in, or logged in without permission. Spring Security produces these before your controller runs.
  • 404 Not Found: the resource does not exist.
  • 409 Conflict: the request clashes with current state, such as a duplicate email or a stale version.
  • 422 Unprocessable Content: the request is well formed but breaks a business rule, such as ordering more than is in stock.
  • 500 Internal Server Error: a bug or an unexpected failure. This should never be used for a problem the client caused.

Examples

A global exception handler covering a custom not-found exception and validation failures

Java
public class ProductNotFoundException extends RuntimeException {
    public ProductNotFoundException(Long id) {
        super("Product not found with id: " + id);
    }
}

public record ErrorResponse(LocalDateTime timestamp, int status, String message, String path) {}

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ProductNotFoundException ex, HttpServletRequest req) {
        ErrorResponse body = new ErrorResponse(LocalDateTime.now(), 404, ex.getMessage(), req.getRequestURI());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ": " + f.getDefaultMessage())
                .collect(Collectors.joining(", "));
        ErrorResponse body = new ErrorResponse(LocalDateTime.now(), 400, message, req.getRequestURI());
        return ResponseEntity.badRequest().body(body);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex, HttpServletRequest req) {
        ErrorResponse body = new ErrorResponse(LocalDateTime.now(), 500, "Unexpected error", req.getRequestURI());
        return ResponseEntity.internalServerError().body(body);
    }
}
Output
GET /api/products/999
-> 404 Not Found
{"timestamp":"2026-09-21T10:20:00","status":404,"message":"Product not found with id: 999","path":"/api/products/999"}

Careful: handleGeneric(Exception) also catches Spring MVC's own exceptions, so
GET /api/no-such-page and GET /api/products/abc come back as 500 "Unexpected error"
instead of 404 and 400. The next example fixes that.

ProblemDetail errors with a safe catch-all (tested on Spring Boot 4.1.1)

Java
// The exception carries its own status and ProblemDetail, so it needs no handler method.
public class ProductNotFoundException extends ErrorResponseException {

    public ProductNotFoundException(long id) {
        super(HttpStatus.NOT_FOUND, problem(id), null);
    }

    private static ProblemDetail problem(long id) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Product " + id + " does not exist");
        problem.setTitle("Product not found");
        problem.setType(URI.create("https://api.webnest.dev/problems/product-not-found"));
        problem.setProperty("productId", id);
        return problem;
    }
}

@RestController
public class StockController {

    @GetMapping("/api/stock/{id}")
    public String stock(@PathVariable long id) {
        if (id == 999) {
            throw new ProductNotFoundException(id);
        }
        if (id == 500) {
            throw new IllegalStateException("database password expired");
        }
        return "product " + id + " has 12 in stock";
    }
}

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // Spring MVC's own exceptions (404 for unknown paths, 400 for bad input,
    // 405, 415 ...) and any ErrorResponseException are handled by the base class
    // with the right status. Only truly unexpected errors reach this method.
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex) {
        log.error("Unhandled error", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "Something went wrong. Please try again later.");
    }
}
Output
GET /api/stock/7    -> 200 product 7 has 12 in stock
GET /api/stock/999  -> 404 {"detail":"Product 999 does not exist","instance":"/api/stock/999","status":404,
                            "title":"Product not found","type":"https://api.webnest.dev/problems/product-not-found","productId":999}
GET /api/stock/500  -> 500 {"detail":"Something went wrong. Please try again later.","instance":"/api/stock/500",
                            "status":500,"title":"Internal Server Error"}
                       (the log holds: IllegalStateException: database password expired)
GET /api/no-such-page -> 404 {"detail":"No static resource api/no-such-page.","status":404,"title":"Not Found",...}
GET /api/stock/abc  -> 400 {"detail":"Failed to convert 'id' with value: 'abc'","status":400,"title":"Bad Request",...}

Common Mistakes

  • Adding a plain @ExceptionHandler(Exception.class) without extending ResponseEntityExceptionHandler, which turns unknown URLs (404) and bad parameters (400) into 500 errors.
  • Logging nothing in the catch-all handler. The client should see a generic message, but the server log must keep the real exception and stack trace.
  • Writing duplicate try-catch blocks in every controller instead of centralizing handling in one @ControllerAdvice class.
  • Catching the generic Exception.class before more specific handlers, which prevents the specific handler from ever being reached (Spring picks the most specific match, but ordering and inheritance mistakes still cause confusion).
  • Leaking internal details (stack traces, SQL exception messages) directly into the client-facing error response.
  • Forgetting @RestControllerAdvice vs @ControllerAdvice, then wondering why the returned object is treated as a view name instead of a JSON body.

Exercise

Using the lesson's A global exception handler covering a custom not-found exception and validation failures example, create a small implementation or design sketch that demonstrates @ControllerAdvice (or @RestControllerAdvice) centralizes exception handling across all controllers.. Add a test case or scenario that would catch Adding a plain @ExceptionHandler(Exception.class) without extending ResponseEntityExceptionHandler, which turns unknown URLs (404) and bad parameters (400) into 500 errors., and describe the expected result.

Show hints
  • Start with @ControllerAdvice and @ExceptionHandler and compare your approach with A global exception handler covering a custom not-found exception and validation failures. Identify the decision or behavior the lesson recommends.
  • Walk through your scenario step by step. Confirm that it follows @ControllerAdvice (or @RestControllerAdvice) centralizes exception handling across all controllers. and that it would catch the failure described by Adding a plain @ExceptionHandler(Exception.class) without extending ResponseEntityExceptionHandler, which turns unknown URLs (404) and bad parameters (400) into 500 errors..

Quick quiz

1. Which choice is a mistake to avoid in Global Exception Handling in Spring Boot?

  • A. @ControllerAdvice (or @RestControllerAdvice) centralizes exception handling across all controllers.
  • B. Adding a plain @ExceptionHandler(Exception.class) without extending ResponseEntityExceptionHandler, which turns unknown URLs (404) and bad parameters (400) into 500 errors.
  • C. Logging nothing in the catch-all handler. The client should see a generic message, but the server log must keep the real exception and stack trace.
Show answer

B. Avoid this approach: Adding a plain @ExceptionHandler(Exception.class) without extending ResponseEntityExceptionHandler, which turns unknown URLs (404) and bad parameters (400) into 500 errors. The lesson recommends: @ControllerAdvice (or @RestControllerAdvice) centralizes exception handling across all controllers.

Interview questions

How would you apply the guidance in @ControllerAdvice and @ExceptionHandler to a real task, and what failure would you guard against?

Show answer

@ControllerAdvice (or @RestControllerAdvice) centralizes exception handling across all controllers. Apply this in @ControllerAdvice and @ExceptionHandler; the example A global exception handler covering a custom not-found exception and validation failures shows one way to do it. Avoid Adding a plain @ExceptionHandler(Exception.class) without extending ResponseEntityExceptionHandler, which turns unknown URLs (404) and bad parameters (400) into 500 errors. and explain how your implementation or design prevents that failure.

Key Points to Remember

  • @ControllerAdvice (or @RestControllerAdvice) centralizes exception handling across all controllers.
  • @ExceptionHandler(SomeException.class) methods intercept a specific exception type wherever it is thrown in request handling.
  • Map each exception type to the correct HTTP status code rather than letting everything fall back to 500.
  • A consistent ErrorResponse shape across all handlers makes the API predictable for clients.
  • ProblemDetail (RFC 9457) is the standard error body; ErrorResponseException subclasses carry their own status and details.
  • Extend ResponseEntityExceptionHandler so a catch-all handler never swallows Spring MVC's 400 and 404 errors.

Practice in the playground

Write and run your own code for this topic, or start from the exercise or an example. Runs plain Java 17. Frameworks such as Spring, and databases, are not available here. Your code for each topic is kept in this browser.

Run the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

These examples need a JDK project with their libraries on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.

WebNest Studio

Written by

WebNest Studio Engineering Team

Practical lessons from developers working with Java, Python, React, databases and production systems.

Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering