← Back to the section

REST API · ERROR CONTRACT Phase 14. Error handling

A single error handler

Java · Spring MVC

@RestControllerAdvice turns exceptions into predictable HTTP responses and ProblemDetail.

1. Controllers avoid repetitive try/catch

1 / 5

Business code throws a meaningful exception while advice centrally selects status, body and logging level.

  • Keep each mapping rule in one place.
  • Let the controller handle the request, not failure classification.
  • Add a dedicated handler for each new error contract.
OrderControllerthrows OrderNotFound @RestControllerAdvice@ExceptionHandlermap to 404 404 JSON

2. A domain failure gets a meaningful status

2 / 5

Not found maps to 404, state conflict to 409 and invalid input to 400. The status describes the outcome rather than an arbitrary Java class.

  • Never return 200 for a rejected operation.
  • Reserve 500 for unexpected failures.
  • Expose a stable machine-readable error code.
OrderNotFound → 404 InvalidState → 409 BadRequest → 400 ProblemDetailtype + title + code Client

3. ProblemDetail is a shared machine-readable format

3 / 5

RFC 9457 defines type, title, status, detail and instance. Extension fields can carry an application code and field violations.

application/problem+json type: /errors/order-not-found title: Order not found status: 404 code: ORDER_NOT_FOUND traceId: 7f3a...

4. Log once at the appropriate level

4 / 5

An expected client rejection rarely needs an ERROR stack trace. An unexpected 500 is logged with cause, context and trace ID.

  • Avoid log-and-throw at every layer.
  • Exclude tokens and personal data.
  • Preserve the original cause.
404 / 409 expectedshort WARN, no stack trace 500 unexpectedERROR + cause + traceId One entry instead of three duplicates

5. A fallback handler covers unexpected exceptions

5 / 5

A general Exception handler returns a neutral 500. Details stay in logs while the client receives the same ProblemDetail contract.

  • Specific handlers take precedence.
  • The error handler itself must not loop.
  • Tests verify status, body and absence of leaks.
Exception resolution Specific exception400 / 404 / 409 Unexpected exceptionsafe 500 ProblemDetail

← 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.