Appearance
Exception Handling: @ExceptionHandler, @ControllerAdvice and @ResponseStatus
Chapter 21 chose the status code when everything goes right. When a controller throws an exception, Spring decides the response, and by default the client gets a 500. This chapter follows that decision step by step, then shows the ways to take control: @ExceptionHandler (in a controller or a @ControllerAdvice), @ResponseStatus, and Spring's own handling of its exceptions.
The classes involved
When a controller throws an exception, or the DispatcherServlet cannot even reach a controller, five classes decide what the client gets back:
HandlerExceptionResolverCompositeis the orchestrator. It passes the exception to the three resolvers in order. After each one it checks: was the exception handled? If yes, it stops; the later resolvers never see it.- The three resolvers all implement the interface
HandlerExceptionResolver(methodresolveException). Each knows how to handle certain exceptions, covered one by one below. DefaultErrorAttributesbuilds the error body:timestamp,status,error,path. A resolver does not build a response itself; it only sets the status (and possibly a message) on the response. Spring Boot'sBasicErrorControllerthen usesDefaultErrorAttributesto fill in the rest. If no resolver handled the exception, the status defaults to 500.
The default: 500
java
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/get-user")
public ResponseEntity<?> getUser() {
throw new CustomException(HttpStatus.BAD_REQUEST, "UserID is missing");
}
}java
public class CustomException extends RuntimeException {
HttpStatus status;
CustomException(HttpStatus status, String message) {
super(message); // stored in Throwable's detailMessage, returned by getMessage()
this.status = status;
}
public HttpStatus getStatus() { return status; }
}The response is 500, not 400:
json
{"timestamp":"2026-09-30T22:43:24.068+00:00","status":500,"error":"Internal Server Error","path":"/api/get-user"}Throwing a NullPointerException gives exactly the same thing. The status field inside CustomException means nothing to Spring: it is just a field. The controller returned no ResponseEntity, so the exception went through the resolvers: resolver 1 finds no @ExceptionHandler, resolver 2 finds no @ResponseStatus, resolver 3 knows only Spring's own exceptions. Nobody handled it, so the status is the default, 500.
Where is the message? By default Spring Boot leaves the message field out of the error body. To include it:
properties
server.error.include-message=alwaysHandling it yourself: try/catch
Many companies skip the resolvers and always build the ResponseEntity themselves:
java
@GetMapping("/get-user")
public ResponseEntity<?> getUser() {
try {
// business logic and validations...
throw new CustomException(HttpStatus.BAD_REQUEST, "UserID is missing");
} catch (CustomException e) {
ErrorResponse body = new ErrorResponse(new Date(), e.getMessage(), e.getStatus().value());
return new ResponseEntity<>(body, e.getStatus());
} catch (Exception e) {
ErrorResponse body = new ErrorResponse(new Date(), "something went wrong", 500);
return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR);
}
}ErrorResponse is a plain class with timestamp, msg and status. The client gets 400 with that body; the resolvers are never involved. It works, but in a large application the same try/catch is repeated in every method of every controller. The resolvers let you write the handling once.
Resolver 1: ExceptionHandlerExceptionResolver
It handles two annotations: @ExceptionHandler and @ControllerAdvice.
@ExceptionHandler: one controller
java
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/get-user")
public ResponseEntity<?> getUser() {
throw new CustomException(HttpStatus.BAD_REQUEST, "UserID is missing");
}
@ExceptionHandler(CustomException.class)
public ResponseEntity<String> handleCustomException(CustomException ex) {
return new ResponseEntity<>(ex.getMessage(), ex.getStatus());
}
}The response is 400 with the body UserID is missing. Whichever method of this controller throws CustomException, this handler runs. The method bodies stay free of try/catch.
The handler can return any body, for example the ErrorResponse object from above (the client then gets JSON). A controller can have several handlers, one per exception type, and one handler can take several exceptions:
java
@ExceptionHandler({CustomException.class, IllegalArgumentException.class})
public ResponseEntity<String> handleBadRequest(RuntimeException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.BAD_REQUEST);
}When a handler takes several exceptions, its parameter must be a common parent (RuntimeException or Exception here).
Parameters. A handler method can take the exception, HttpServletRequest and HttpServletResponse, in any order, any of them or none. The resolver knows how to fill each one. It cannot fill something like a String parameter: the handler then fails ("Failure in @ExceptionHandler" in the log) and the client gets 500.
Setting the response instead of returning it. A handler can also return nothing and just set the status and message on the response. DefaultErrorAttributes then builds the body:
java
@ExceptionHandler(CustomException.class)
public void handleCustomException(HttpServletResponse response, CustomException ex) throws IOException {
response.sendError(ex.getStatus().value(), ex.getMessage());
}json
{"timestamp":"...","status":400,"error":"Bad Request","message":"UserID is missing","path":"/api/get-user"}(The message field appears with server.error.include-message=always.)
@ControllerAdvice: all controllers
A controller-level handler only covers its own controller. With a hundred controllers, you would copy the same handler into each. Instead, put it once in a class annotated with @ControllerAdvice:
java
@ControllerAdvice
public class GlobalExceptionHandling {
@ExceptionHandler(CustomException.class)
public ResponseEntity<String> handleCustomException(CustomException ex) {
return new ResponseEntity<>(ex.getMessage() + ": from Global ExceptionHandler", ex.getStatus());
}
}Now a CustomException thrown from any controller (user, invoice, order, ...) is handled here. @RestControllerAdvice is the same plus @ResponseBody, so a returned object is written as JSON without needing a ResponseEntity.
Which handler wins?
Controller or global? If both the controller and the @ControllerAdvice class have a handler for CustomException, the controller's runs (UserID is missing: from Controller ExceptionHandler). Only if the controller has none is the global one used.
Exact type or parent? With two handlers that could both take the exception:
java
@ControllerAdvice
public class GlobalExceptionHandling {
@ExceptionHandler(CustomException.class)
public ResponseEntity<String> handleCustomException(CustomException ex) {
return new ResponseEntity<>(ex.getMessage(), ex.getStatus());
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> handleRuntimeException(RuntimeException ex) {
return new ResponseEntity<>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}The exact match wins: CustomException goes to the first. Any other RuntimeException goes to the second. Spring looks from the bottom of the hierarchy up: the exception's own class, then its parent, and so on.
Resolver 2: ResponseStatusExceptionResolver
It handles uncaught exceptions whose class is annotated with @ResponseStatus. "Uncaught" means no @ExceptionHandler handled it (resolver 1 came first).
java
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class CustomException extends RuntimeException {
CustomException(String message) {
super(message);
}
}Throwing it now gives 400, with the exception's message (UserID is missing) as message. @ResponseStatus has only two things to set: value (the status) and reason (the message). With a reason:
java
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Invalid Request Passed")
public class CustomException extends RuntimeException { ... }the message is Invalid Request Passed: the reason takes priority over the exception's own message.
Like every resolver, it only sets the status and reason on the response; DefaultErrorAttributes builds the body.
@ResponseStatus on an @ExceptionHandler method
This is where people get confused:
java
@ExceptionHandler(CustomException.class)
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Invalid Request Sent")
public ResponseEntity<Object> handleCustomException(CustomException e) {
return new ResponseEntity<>("you are not authorized", HttpStatus.FORBIDDEN);
}What does the client get: 403 or 400?
A common wrong answer: "resolver 1 sets 403, then resolver 2 sees @ResponseStatus and overrides it to 400". Resolver 2 never runs: resolver 1 already handled the exception. Yet the client does get 400 with message Invalid Request Sent, and the 403 and its body are thrown away. The override comes from Spring's normal method handling (ServletInvocableHandlerMethod), which applies a method's @ResponseStatus after invoking any handler method, not from the resolver. The log even warns: "Return value ... will be ignored since @ResponseStatus 'reason' attribute is set".
Now the same with sendError instead of returning a ResponseEntity:
java
@ExceptionHandler(CustomException.class)
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Invalid Request Sent")
public void handleCustomException(CustomException e, HttpServletResponse response) throws IOException {
response.sendError(HttpStatus.FORBIDDEN.value(), "you are not authorized");
}The client gets 500. sendError sets the status and message and commits the response. Spring then applies @ResponseStatus by calling sendError again, which fails on a committed response. That exception escapes the handler, and the result is the default 500.
So: do not use @ExceptionHandler and @ResponseStatus together. If you must, give one of them the whole job, for example:
java
@ExceptionHandler(CustomException.class)
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Invalid Request Sent")
public void handleCustomException(CustomException e) {
// do nothing here: @ResponseStatus sets the response
}Resolver 3: DefaultHandlerExceptionResolver
It handles Spring's own exceptions with fixed statuses. Nothing to write; for example:
| Situation | Exception | Status |
|---|---|---|
No API at that path (/api/get-user-3223) | NoResourceFoundException | 404 |
| GET API called with POST | HttpRequestMethodNotSupportedException | 405 |
A required @RequestParam missing | MissingServletRequestParameterException | 400 |
Unsupported Content-Type | HttpMediaTypeNotSupportedException | 415 |
For the first, the message is No static resource api/get-user-3223.: Spring found no controller and looked for a static file.
Summary of the order
@ExceptionHandlerin the controller, then in@ControllerAdvice(exact type before parent). Resolver 1.@ResponseStatuson the exception class. Resolver 2.- Spring's own exceptions. Resolver 3.
- Nothing matched: 500.
Next: That completes requests and responses. So far no data has been stored anywhere real. Chapter 23 starts the data-access module: the layers between your code and the database, plain JDBC, and Spring's JdbcTemplate.
Check yourself
- Name the three resolvers in the order they run. What does the composite class do between them?
- A controller throws
new CustomException(HttpStatus.BAD_REQUEST, "...")and nothing handles it. Which status does the client get, and why? - Why is the
messagefield missing from the default error body, and how do you add it? - What does
@ControllerAdviceadd over a controller-level@ExceptionHandler? - The controller and a
@ControllerAdviceboth handleCustomException. Which runs? - Handlers for
CustomExceptionandRuntimeExceptionboth exist. Which handlesCustomException?IllegalStateException? - What does "uncaught" mean in the definition of
ResponseStatusExceptionResolver? @ResponseStatus(value = BAD_REQUEST, reason = "Invalid Request Passed")is on the exception class. What status and message does the client get?- An
@ExceptionHandlermethod with@ResponseStatus(BAD_REQUEST)returns a 403ResponseEntity. What does the client get, and which class applies the 400? - The same, but the method calls
response.sendError(403, ...). What happens and why? - A client calls an API path that does not exist. Which resolver handles it, and what status results?
Answers
ExceptionHandlerExceptionResolver,ResponseStatusExceptionResolver,DefaultHandlerExceptionResolver. After each, it checks whether the exception was handled and, if so, stops.- 500. The
statusfield is just a field; no resolver handles the exception, so the default status is used. - Spring Boot leaves it out by default. Set
server.error.include-message=always. - It applies to all controllers, so the same handler is not copied into each one.
- The controller's.
- The
CustomExceptionhandler (exact match). TheRuntimeExceptionhandler (its parent). - No
@ExceptionHandlerhandled the exception. - 400 with message
Invalid Request Passed(the reason beats the exception's message). - 400 with
Invalid Request Sent; the 403 and body are discarded. Spring's handler-method invocation (ServletInvocableHandlerMethod) applies it; resolver 2 never runs. - 500.
sendErrorcommits the response; applying@ResponseStatuscallssendErroragain, which fails. DefaultHandlerExceptionResolver, forNoResourceFoundException: 404.