MoreExtra· 45 min read

Global Exception Handling & Uniform Errors

A job-ready API returns the same tidy error shape for every failure — done in one central place with @ControllerAdvice, not scattered try/catch.

What you will learn

  • Build one @RestControllerAdvice that handles all errors
  • Return a consistent JSON error body for every failure
  • Handle validation errors cleanly

Why one central handler?

In the last lesson you saw @RestControllerAdvice catch one exception type. The real power is bigger: it lets you handle every error from every controller in one class, so all your errors come back in the same shape. That consistency is what separates a hobby API from a professional one.

Imagine a front-end developer using your API. If a 404 returns {"error":"..."}, a validation error returns a Spring default blob, and a server error returns a stack trace, they have to handle three different shapes. With a global handler, every error looks the same, so they write the error-handling code once.

Step 1: define one error shape

First decide the JSON every error will use. A small DTO does the job — a timestamp, a status code, and a message:

A single, consistent error body for the whole API
public class ApiError {
    private Instant timestamp = Instant.now();
    private int status;
    private String message;
    public ApiError(int status, String message) {
        this.status = status; this.message = message;
    }
    // getters ...
}

Note: Output: (No output — this just defines the shape every error response will take, e.g. {"timestamp":"...","status":404,"message":"..."}.)

Step 2: the global handler

Now one @RestControllerAdvice class catches the different error types and turns each into an ApiError with the right status. ResponseEntity lets us set both the body and the HTTP status:

One class that handles 404, 400 and 500 uniformly
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 404 — our own "not found" exception
    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ApiError> handleNotFound(NotFoundException ex) {
        ApiError body = new ApiError(404, ex.getMessage());
        return ResponseEntity.status(404).body(body);
    }

    // 400 — bean validation failures (@Valid)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
        String msg = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining(", "));
        return ResponseEntity.status(400).body(new ApiError(400, msg));
    }

    // 500 — the safety net for anything unexpected
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleAny(Exception ex) {
        return ResponseEntity.status(500)
            .body(new ApiError(500, "Something went wrong"));
    }
}

Note: Output (three different failures, one consistent shape): GET /products/999 (missing) -> 404 {"timestamp":"2026-06-13T10:00:00Z","status":404,"message":"No product with id 999"} POST /products {"name":"","price":-5} (invalid) -> 400 {"timestamp":"...","status":400,"message":"name: Name is required, price: Price cannot be negative"} Any unexpected crash -> 500 {"timestamp":"...","status":500,"message":"Something went wrong"} Three very different problems, but every response has the same fields — timestamp, status, message. The 500 handler hides the internal details and never leaks a stack trace.

How a request error flows through the handler

  1. A controller method throws an exception (or @Valid rejects the body).
  2. Instead of crashing to the client, Spring looks for a matching @ExceptionHandler in any @RestControllerAdvice.
  3. It finds the most specific match — NotFoundException for a missing record, MethodArgumentNotValidException for a validation failure, or the Exception catch-all for anything else.
  4. That handler builds an ApiError and returns it with the right status.
  5. The client always receives the same JSON shape, so it can handle every error the same way.

Tip: Order from specific to general. Spring picks the most specific matching handler, so NotFoundException wins over the broad Exception catch-all. The Exception handler is your safety net — it guarantees no raw stack trace ever escapes to a client.

Watch out: Do not put the unexpected-error message into the response when it might contain internal details (database errors, file paths). Log the full exception on the server for yourself, but send the client only a short, generic message like “Something went wrong”.

Q. What is the main benefit of a global @RestControllerAdvice over try/catch in each controller?

Answer: A global handler centralizes error handling: one class catches errors from all controllers and returns a uniform error body, so clients always get the same shape and you do not repeat try/catch everywhere.

✍️ Practice

  1. Build an ApiError DTO and a @RestControllerAdvice that handles NotFoundException (404) and validation errors (400).
  2. Trigger each error and confirm the JSON has the same timestamp/status/message fields.

🏠 Homework

  1. Give your Task API one global exception handler that returns a uniform error body for 404 (missing task), 400 (validation) and a 500 catch-all, and verify all three look identical in shape.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →