Global error handling and consistent error responses
When something goes wrong — a parcel is not found, input is invalid, a business rule is violated — your API
must return a sensible, consistent error the client can handle. Scatter try/catch and manual error
responses through your controllers and you get inconsistency and noise. Spring's answer is
@RestControllerAdvice: one place that turns exceptions into HTTP responses, so your controllers throw
and stay clean. This lesson builds Dakiya's global error handling, verified against the running API.
The problem: error handling scattered in controllers
Without a strategy, every controller method handles its own errors:
@GetMapping("/{id}")
public ResponseEntity<?> getOne(@PathVariable Long id) {
Optional<Parcel> p = repository.findById(id);
if (p.isEmpty()) {
return ResponseEntity.status(404).body(Map.of("error", "not found")); // repeated everywhere
}
return ResponseEntity.ok(ParcelResponse.from(p.get()));
}
Every method repeats the not-found handling, error shapes drift (one returns {"error":...}, another
{"message":...}), and the happy-path logic is buried in error checks. This does not scale.
The pattern: throw, and handle centrally
The clean approach: controllers throw an exception when something is wrong, and one central handler turns each exception type into a response. The controller becomes simple:
@GetMapping("/{id}")
public ParcelResponse getOne(@PathVariable Long id) {
Parcel p = repository.findById(id)
.orElseThrow(() -> new NotFoundException("No parcel with id " + id)); // just throw
return ParcelResponse.from(p);
}
With a small exception type:
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) { super(message); }
}
The controller now expresses only the happy path plus "throw if not found" — no status codes, no error bodies. Where do those come from? The advice.
@RestControllerAdvice: one handler for the whole app
A class annotated @RestControllerAdvice contains @ExceptionHandler methods that catch exceptions from
any controller and turn them into responses:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
ProblemDetail handleNotFound(NotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class) // validation failures (previous lesson)
ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage()));
pd.setProperty("errors", errors);
return pd;
}
}
Each @ExceptionHandler maps an exception type to a response. When a controller throws NotFoundException,
Spring finds the matching handler and returns its result. Verified against the running API:
GET /api/parcels/999 (no such parcel) returned HTTP 404 with body {"type":"about:blank","title":"Not Found","status":404,"detail":"No parcel with id 999","instance":"/api/parcels/999"}, and an invalid POST
returned the 400 with the field-errors map. Every controller in the app gets this handling for free — throw
the exception, the advice shapes the response.
ProblemDetail: the standard error format
Notice the handlers return ProblemDetail. This is Spring's built-in implementation of RFC 9457 (Problem
Details for HTTP APIs) — a standard JSON error shape with type, title, status, detail, and
instance, plus your own properties (like the errors map). Using the standard format matters: clients and
tools understand it, and every error in your API looks the same, so a client writes one piece of
error-handling code. Verified: both the 404 and the 400 came back as ProblemDetail-shaped JSON with the
consistent fields. You can add custom fields with pd.setProperty(...) (as the validation errors map shows).
Prefer ProblemDetail over hand-rolled error objects — it is the modern Spring standard and gives you
consistency for free.
Mapping the right status to the right error
Good error handling means the right HTTP status for each situation — this is a REST contract, not decoration:
- 400 Bad Request — malformed or invalid input (validation failures, unparseable body).
- 401 Unauthorized — not authenticated (the security module).
- 403 Forbidden — authenticated but not allowed.
- 404 Not Found — the resource does not exist (Dakiya's
NotFoundException). - 409 Conflict — a conflict with current state (a duplicate, a violated unique constraint).
- 422 Unprocessable Entity — well-formed but semantically invalid (sometimes used instead of 400 for business-rule failures).
- 500 Internal Server Error — an unexpected failure (a bug); Spring returns this for uncaught exceptions.
Map each of your exception types to the status that honestly describes what happened — a not-found is 404,
not 500; invalid input is 400, not 200-with-an-error-in-the-body. A catch-all handler for Exception can
return a generic 500 (and log it, per the errors-and-logging lesson) so an unexpected bug does not leak a
stack trace to the client. The principle: controllers throw meaningful exceptions; one advice maps each to
the correct status and a consistent ProblemDetail — clean controllers, consistent errors, right status
codes.
Check your work
The problem. Per-controller error handling repeats itself, drifts in shape, and buries the happy path.
The pattern. Controllers throw meaningful exceptions (e.g. NotFoundException); a central handler maps
them to responses. Controllers stay simple.
@RestControllerAdvice. A class of @ExceptionHandler methods catching exceptions from all controllers;
each maps an exception type to a response. Verified: NotFoundException → 404, validation → 400 with errors,
app-wide.
ProblemDetail. Spring's RFC 9457 standard error format (type/title/status/detail/instance +
custom properties) — consistent, tool-understood errors; prefer it over hand-rolled error objects. Verified
shape on both 404 and 400.
Right status codes. 400 (invalid input), 401/403 (auth), 404 (not found), 409 (conflict), 422 (semantic), 500 (unexpected) — map each exception to the honest status; a catch-all 500 handler (with logging) prevents leaking stack traces.
Practice
- Create a
NotFoundExceptionand throw it from a GET when the parcel is absent (orElseThrow); confirm the controller has no error-handling code. - Add a
@RestControllerAdvicewith an@ExceptionHandler(NotFoundException.class)returning a 404ProblemDetail; hit a missing id and confirm the verified 404 body. - Add the validation handler; POST invalid input and confirm the 400 with the field-errors map.
- Add a second controller that also throws
NotFoundExceptionand confirm it gets the same handling for free (app-wide advice). - Add a catch-all
@ExceptionHandler(Exception.class)returning 500; trigger an unexpected error and confirm no stack trace leaks to the client (and that it is logged). - For five error situations in Dakiya, choose the correct HTTP status and justify each.
Official documentation
- Spring — Error handling (@ExceptionHandler, @ControllerAdvice) — Centralised exception handling.
- Spring — ProblemDetail and RFC 9457 — The standard error format.
- MDN — HTTP response status codes — Which status means what.
Next: REST API design — status codes, versioning, pagination.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship