Stage 12 - API Error Handling with ControllerAdvice
Error handling becomes visible the first time a frontend developer asks: “What exactly should I show to the user when this request fails?” If every controller returns a different error shape, the frontend cannot build one normal error component. One endpoint returns plain text, another returns an HTML error page, the third returns a stack trace, and validation errors arrive in a completely different format. The backend may technically work, but the API is unpleasant and unreliable.
Spring gives a better model: controllers should describe successful HTTP actions, services should throw meaningful exceptions when a use case cannot be completed, and one global handler should translate those exceptions into a stable JSON error contract. That global handler is usually a class annotated with @RestControllerAdvice. It listens to exceptions thrown from controllers and turns them into ResponseEntity objects with the right HTTP status and response body.

Request flow
The flow is simple. A request reaches OrderController. The controller calls orderService.cancel(orderId). The service loads the order, checks ownership and current status, and throws OrderNotFoundException, AccessDeniedException, or OrderAlreadyShippedException when the operation cannot continue. The controller does not catch these exceptions. They move to the advice class, where each exception type is mapped to a clear status and JSON body.
| Situation | HTTP status | Meaning |
|---|---|---|
| Broken JSON or invalid field | 400 Bad Request | The client sent data the API cannot accept. |
| Order id does not exist | 404 Not Found | The requested resource is absent. |
| Order belongs to another user | 403 Forbidden | The user is known but not allowed to access it. |
| Order already shipped | 409 Conflict | The request conflicts with current business state. |
| Unexpected bug or database outage | 500 Internal Server Error | The server failed and should hide internals. |
Concrete Spring example
public record ApiError(
String code,
String message,
List<FieldErrorDto> fieldErrors
) {}
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ApiError> notFound(OrderNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("ORDER_NOT_FOUND", ex.getMessage(), List.of()));
}
@ExceptionHandler(OrderAlreadyShippedException.class)
ResponseEntity<ApiError> conflict(OrderAlreadyShippedException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError("ORDER_ALREADY_SHIPPED", ex.getMessage(), List.of()));
}
}
Validation errors
Validation errors need one more handler because Spring throws MethodArgumentNotValidException before the controller method body runs. In that handler you read field errors and return a list like field=quantity, message=must be greater than or equal to 1. This is much more useful than a generic “Bad Request”.
The important rule is that the error body is part of the API contract. Do not put random exception class names into code. Do not expose SQL messages. Do not return 200 OK with an error field inside the JSON. Use HTTP status for transport meaning and a stable code for business meaning.
Common mistakes
- Catching exceptions in every controller and returning different response shapes.
- Returning stack traces, SQL errors, or Java exception names to clients.
- Using
500for business problems such as “order already shipped”. - Returning
200 OKfor failed operations because it is easier for the controller.
Understanding checklist
- I can map common API failures to the right HTTP status.
- I can explain why
@RestControllerAdvicebelongs outside individual controllers. - I can design a stable JSON error body for frontend and integration clients.
Self-check questions
- Why should the service throw a business exception instead of returning
null? - What should a validation error response contain?
- Why is a stack trace dangerous in a public API response?