Once you can catch exceptions across the board with @RestControllerAdvice, the next thing you run into is the question: “Will this handler really hold up in production?” You don’t want to return stack traces to the client, but you do want to keep them in the server-side logs—and when an inquiry comes in, you need to be able to trace which request it was.

This article assumes you already understand the basic syntax, and focuses on three points to raise the operational quality: log design, attaching a trace ID via MDC, and extending properties on ProblemDetail.

For the basic syntax of @ExceptionHandler and an explanation of RFC 9457 itself, I’ll leave those to other articles. If you’re interested, reading Spring Boot Exception Handling Basics or the Problem Details (RFC 9457) Guide first will make things go smoother.

Why the basic syntax alone isn’t enough

The basic handler only goes as far as “catch an exception and return a response.” When you take it to production, you’ll typically run into three issues:

  • Separating what’s safe to send to the client from the details kept in the logs
  • A trace ID that lets you follow up on inquiries
  • Custom properties on ProblemDetail to carry extended information

Conversely, if you nail these three, your operational quality goes up considerably. Let’s go through them in order.

The base skeleton

First, here’s the skeleton that the extensions build on. Extending ResponseEntityExceptionHandler is convenient because it lets you handle Spring’s standard exceptions together.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(BusinessException.class)
    public ProblemDetail handleBusiness(BusinessException ex, HttpServletRequest req) {
        log.warn("business error: code={}, path={}", ex.getCode(), req.getRequestURI());
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
        return decorate(pd, req, ex.getCode());
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest req) {
        log.error("unexpected error at {}", req.getRequestURI(), ex);
        var pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "内部エラーが発生しました");
        return decorate(pd, req, "INTERNAL_ERROR");
    }
}

The key point is splitting the handlers between business exceptions and unexpected exceptions. Whether to pass the detail message straight through to the client is a clear-cut choice based on the type of exception.

Log design - WARN for 4xx, ERROR for 5xx

The log level policy can be simple: client-caused (4xx) is WARN, server-caused (5xx) is ERROR as the baseline. Emitting a stack trace on every WARN clutters your production logs, so keeping business exceptions to a one-line summary makes things much easier to scan.

And the iron rule for the detail you send back to the client: never put the raw message in directly. ex.getMessage() tends to contain SQL fragments or internal paths, so for business exceptions, pass only a pre-sanitized message to detail and record the raw information on the log side.

} catch (DataAccessException ex) {
    log.error("DB access failed: sql={}", maskSql(extractSql(ex)), ex);
    throw new BusinessException("DATA_ERROR", "データ取得に失敗しました");
}

Unexpected exceptions, on the other hand, should always retain the stack trace. Otherwise, you won’t be able to track down the cause later.

Attaching a trace ID with MDC

The most valuable thing in production is being able to grep your logs by the traceId carried in the response. A single OncePerRequestFilter is all you need.

@Component
public class TraceIdFilter extends OncePerRequestFilter {
    public static final String KEY = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        var traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
                .orElse(UUID.randomUUID().toString());
        MDC.put(KEY, traceId);
        res.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.remove(KEY);
        }
    }
}

If you forget MDC.remove in the finally block, the value from the previous request will leak in a servlet container that reuses threads, so this part is non-negotiable. If you’ve already adopted Micrometer Tracing, you can do essentially the same thing via Tracer#currentSpan, in which case you don’t need to generate the ID yourself.

Add %X{traceId} to your Logback pattern and the ID will appear on every log line.

<pattern>%d{ISO8601} [%X{traceId:-}] %-5level %logger{36} - %msg%n</pattern>

Adding extension properties to ProblemDetail

This is the main subject of the article. With ProblemDetail#setProperty, you can add your own fields while staying within the shape defined by RFC 9457. I recommend preparing a single shared helper.

private ProblemDetail decorate(ProblemDetail pd, HttpServletRequest req, String errorCode) {
    pd.setProperty("traceId", MDC.get("traceId"));
    pd.setProperty("errorCode", errorCode);
    pd.setProperty("timestamp", Instant.now().toString());
    pd.setProperty("path", req.getRequestURI());
    return pd;
}

The response will look like this:

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "ユーザIDが不正です",
  "traceId": "a3f1b8e2-7c4d-4f2a-9b1e-2d6c8a1f3b50",
  "errorCode": "USER_INVALID_ID",
  "timestamp": "2026-05-21T03:12:45.812Z",
  "path": "/api/users/abc"
}

The client can branch its handling on errorCode, and for support inquiries, you can take traceId straight to your log search.

Returning standard exceptions in the same shape

For exceptions Spring throws itself, like validation errors, you’ll want to keep these extension properties consistent too. Override the methods on ResponseEntityExceptionHandler.

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex, HttpHeaders headers,
        HttpStatusCode status, WebRequest request) {

    var pd = ProblemDetail.forStatus(status);
    pd.setDetail("入力値に誤りがあります");
    pd.setProperty("traceId", MDC.get("traceId"));
    pd.setProperty("errorCode", "VALIDATION_ERROR");
    pd.setProperty("timestamp", Instant.now().toString());
    pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
            .toList());
    return new ResponseEntity<>(pd, headers, status);
}

Putting per-field details in the errors array makes form error rendering on the frontend much easier.

Verifying the behavior

To wrap up, check that the traceId in the response matches the one in the logs. The quickest way is to temporarily create an endpoint that deliberately returns a 500 and hit it.

curl -s http://localhost:8080/api/_debug/boom | jq -r .traceId
# => a3f1b8e2-7c4d-4f2a-9b1e-2d6c8a1f3b50

grep a3f1b8e2-7c4d-4f2a-9b1e-2d6c8a1f3b50 logs/app.log

If you can take the traceId from the response, look up the logs with it, and reach the stack trace for that specific request, you’re good. Keeping things in that state will significantly speed up your initial response to production incidents.

Summary

@RestControllerAdvice only really starts to shine once you go beyond the basics—nailing the three points of log design, trace ID, and ProblemDetail extension transforms it into something that can stand up to operations. In particular, you’ll never regret putting in a mechanism that aligns traceId between the response and the logs.

If you want to take searchability further by turning the logs themselves into JSON, take a look at the Structured Logging Implementation Guide as well.