Once an application has more than one controller, handling exceptions without a shared center turns into a repetitive boilerplate: the same try/catch in every method. @RestControllerAdvice solves this — a single class intercepts exceptions from across the whole application and turns them into HTTP responses.
The problem: try/catch in every controller
Without a global handler, a controller looks like this:
@GetMapping("/{id}")
public ResponseEntity<Order> getOrder(@PathVariable UUID id) {
try {
return ResponseEntity.ok(orderService.findById(id));
} catch (OrderNotFoundException e) {
return ResponseEntity.notFound().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}
The problems are obvious: the logic is duplicated, every developer picks their own response format, and the error-handling test has to be written separately for each method.
The short formula: a controller should describe the happy path; what happens on error is a cross-cutting concern.
@RestControllerAdvice: one center
@RestControllerAdvice is @ControllerAdvice + @ResponseBody. A class annotated with it applies to every controller in the application:
@RestControllerAdvice
public class GlobalExceptionHandler {
// @ExceptionHandler methods
}
Spring MVC intercepts an exception thrown from a controller (or a layer beneath it) and dispatches it to the matching @ExceptionHandler inside this class.
@ExceptionHandler: mapping an exception to HTTP
Each @ExceptionHandler method is responsible for one or more exception types:
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}
ProblemDetail is the standard error response format (RFC 9457), built into Spring Boot 3. For more on the response structure, see the article REST errors and Problem Details.
Mapping domain exceptions to HTTP codes
The typical scheme: the domain layer throws a semantic exception, and the handler translates it into an HTTP code. The controller itself knows nothing about statuses:
| Domain exception | HTTP status |
|---|---|
NotFoundException | 404 Not Found |
ConflictException | 409 Conflict |
AccessDeniedException | 403 Forbidden |
ValidationException | 400 Bad Request |
The basic hierarchical approach — a single exception hierarchy in the domain module and a single @ExceptionHandler method on the base type:
@ExceptionHandler(DomainException.class)
public ResponseEntity<ProblemDetail> handleDomain(DomainException ex) {
HttpStatus status = ex.getHttpStatus(); // method on the base class
ProblemDetail body = ProblemDetail.forStatusAndDetail(status, ex.getMessage());
return ResponseEntity.status(status).body(body);
}
What to log in the handler
The rule: log where the exception is handled, not where it is thrown. The level depends on the severity:
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), ex);
return ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
log.debug("Order not found: {}", ex.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
ERROR/WARN— unexpected exceptions, infrastructure failures.DEBUG— expected domain errors (not found, conflict): you can enable them while debugging, but they should not clutter production logs.- Stack trace — only at the
ERRORlevel; atINFO/DEBUGit is not needed.
In short
@RestControllerAdviceis the single point where exceptions turn into HTTP responses; controllers stay clean.@ExceptionHandlerinside it binds an exception type to an HTTP status and a response body.- The domain layer throws semantic exceptions;
GlobalExceptionHandlertranslates them into HTTP codes — the layers do not mix. ProblemDetail(Spring Boot 3, RFC 9457) is the standard error body format.- Log in the handler:
ERRORfor the unexpected,DEBUGfor the expected; stack trace only atERROR. - A single handler for the whole application is easier to test:
@WebMvcTest+MockMvccovers error scenarios centrally.
What to read next
- The error model and Problem Details — how to choose the error body structure and when extensions are needed.
- Common mistakes in exception handling — anti-patterns that hide problems instead of solving them.
- REST errors and Problem Details — HTTP codes, headers, and the response format for a REST API.