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:
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:
@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
- A controller method throws an exception (or
@Validrejects the body). - Instead of crashing to the client, Spring looks for a matching
@ExceptionHandlerin any@RestControllerAdvice. - It finds the most specific match —
NotFoundExceptionfor a missing record,MethodArgumentNotValidExceptionfor a validation failure, or theExceptioncatch-all for anything else. - That handler builds an
ApiErrorand returns it with the right status. - 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?
✍️ Practice
- Build an
ApiErrorDTO and a@RestControllerAdvicethat handlesNotFoundException(404) and validation errors (400). - Trigger each error and confirm the JSON has the same timestamp/status/message fields.
🏠 Homework
- 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.