← Back to the section

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.

GET /orders/{id} — the controller describes only the happy path OrderController: return ok(...)not a single try/catch in the method throw new NotFoundExceptionthe domain knows nothing about HTTP no catch in the method — Spring hands the exception to the advice@RestControllerAdvice — one class for the whole application Validation→ 400AccessDenied→ 403NotFound→ 404Conflict→ 409BusinessRule→ 422 HTTP 404 · application/problem+jsonlog.debug — an expected error, no stack trace without a shared handler the same branching repeats in every method20 methods × 5 types = 100 catch branches · the advice has 5, in one class

The mapping from exception type to HTTP status is declared once: five handlers instead of a hundred catch branches scattered across methods — and the log level is chosen in the same place where the error is handled.

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 exceptionHTTP status
NotFoundException404 Not Found
ConflictException409 Conflict
AccessDeniedException403 Forbidden
ValidationException400 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 ERROR level; at INFO/DEBUG it is not needed.

In short

  • @RestControllerAdvice is the single point where exceptions turn into HTTP responses; controllers stay clean.
  • @ExceptionHandler inside it binds an exception type to an HTTP status and a response body.
  • The domain layer throws semantic exceptions; GlobalExceptionHandler translates 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: ERROR for the unexpected, DEBUG for the expected; stack trace only at ERROR.
  • A single handler for the whole application is easier to test: @WebMvcTest + MockMvc covers error scenarios centrally.