A field guide

Mastering
@ControllerAdvice
in modern Spring.

A practical, opinionated walkthrough of cross-cutting concerns in Spring Boot 3.5.13 — global exception handling, structured logging, audit trails, and reusable patterns powered by Lombok and Java 21.

§ 00 — Premise

What is @ControllerAdvice, really?

It's a specialized @Component that lets you intercept and influence the behavior of every controller in your application — without modifying the controllers themselves.

In a Spring Boot application, your @RestController classes shouldn't be drowning in try/catch blocks, manual logging, audit calls, or repeated header manipulation. Those concerns belong somewhere else — somewhere central, declarative, and reusable. That somewhere is @ControllerAdvice.

It exposes three primary hooks:

  • @ExceptionHandler — catch exceptions thrown anywhere in your controllers and translate them into clean HTTP responses.
  • @ModelAttribute — inject values that should be available to all (or selected) controllers.
  • @InitBinder — register custom property editors and validators globally.

Combined with Java 21 features (records, pattern matching, sealed types) and Lombok's annotation-driven boilerplate elimination, you can build an extraordinarily clean cross-cutting layer.

▲ Vocabulary

@RestControllerAdvice = @ControllerAdvice + @ResponseBody. Use it for REST APIs (the common case). Use plain @ControllerAdvice when you need to return view names from MVC handlers.

◆ ◆ ◆
§ 01 — Setup

Project bootstrap

We'll start with a Spring Boot 3.5.13 project running on Java 21 with Lombok and Bean Validation enabled. The dependency selection below is intentionally minimal — every entry earns its place.

Maven (pom.xml)

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.13</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>advisory-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
        <maven.compiler.release>21</maven.compiler.release>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Gradle (build.gradle.kts)

build.gradle.kts
plugins {
    java
    id("org.springframework.boot") version "3.5.13"
    id("io.spring.dependency-management") version "1.1.6"
}

group = "com.example"
version = "1.0.0"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-aop")

    compileOnly("org.projectlombok:lombok")
    annotationProcessor("org.projectlombok:lombok")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

application.yml

application.yml
spring:
  application:
    name: advisory-demo
  mvc:
    problemdetails:
      enabled: true   # Enables RFC 7807 ProblemDetail by default

server:
  port: 8080
  error:
    include-message: always
    include-binding-errors: always

logging:
  level:
    root: INFO
    com.example: DEBUG
  pattern:
    console: "%d{HH:mm:ss.SSS} %highlight(%-5level) [%X{requestId:-}] %cyan(%logger{36}) - %msg%n"
⬢ Note on RFC 7807

Spring Boot 3.x ships with native support for ProblemDetail (RFC 7807) — the standardized error response format. We'll lean into it heavily because it gives clients a consistent contract for errors.

◆ ◆ ◆
§ 02 — Foundations

Your first global handler

Let's start with the simplest possible @RestControllerAdvice: catch any uncaught exception and return a clean 500 response. Notice how Lombok's @Slf4j eliminates the manual logger declaration.

The minimal advice

GlobalExceptionHandler.java
package com.example.advice;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;
import java.time.Instant;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleAny(Exception ex) {
        log.error("Unhandled exception bubbled up to the advice", ex);

        var problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred. Please try again later."
        );
        problem.setTitle("Internal Server Error");
        problem.setType(URI.create("https://api.example.com/errors/internal"));
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

That's it. Drop this class into a package scanned by your @SpringBootApplication and every uncaught exception thrown by any controller becomes a structured JSON response.

A quick controller to test against

UserController.java
package com.example.user;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor   // Lombok: generates a constructor for final fields
public class UserController {

    private final UserService userService;

    @GetMapping("/{id}")
    public UserDto findOne(@PathVariable Long id) {
        return userService.findById(id);     // may throw UserNotFoundException
    }

    @PostMapping
    public UserDto create(@RequestBody @jakarta.validation.Valid CreateUserRequest req) {
        return userService.create(req);
    }
}

@RequiredArgsConstructor is your friend. The combination of final fields + this annotation is the canonical modern Spring constructor injection pattern — no @Autowired, no manual constructor.

⚠ Catch-all gotcha

A handler for Exception.class swallows everything, including framework exceptions Spring would otherwise handle correctly (like MethodArgumentNotValidException). Always declare more specific handlers before the catch-all — Spring resolves them by specificity, but explicit ordering is a sound practice.

◆ ◆ ◆
§ 03 — Domain

A reusable exception hierarchy

Throwing RuntimeException with a string message is fine for prototypes. For a real application you want a typed hierarchy that carries enough metadata for your advice to translate into a meaningful HTTP response. Java 21's sealed interfaces are perfect for this — they make the set of error types closed and exhaustive.

The base abstraction

ApiException.java
package com.example.error;

import lombok.Getter;
import org.springframework.http.HttpStatus;

import java.util.Map;

@Getter   // Lombok generates getters for all fields
public abstract class ApiException extends RuntimeException {

    private final HttpStatus status;
    private final String code;          // machine-readable, e.g. "USER_NOT_FOUND"
    private final Map<String, Object> context;

    protected ApiException(HttpStatus status, String code, String message,
                           Map<String, Object> context, Throwable cause) {
        super(message, cause);
        this.status = status;
        this.code = code;
        this.context = context == null ? Map.of() : Map.copyOf(context);
    }

    protected ApiException(HttpStatus status, String code, String message) {
        this(status, code, message, null, null);
    }
}

Concrete domain exceptions

UserNotFoundException.java
package com.example.user;

import com.example.error.ApiException;
import org.springframework.http.HttpStatus;

import java.util.Map;

public final class UserNotFoundException extends ApiException {
    public UserNotFoundException(Long userId) {
        super(HttpStatus.NOT_FOUND,
              "USER_NOT_FOUND",
              "No user exists with id " + userId,
              Map.of("userId", userId),
              null);
    }
}

public final class DuplicateEmailException extends ApiException {
    public DuplicateEmailException(String email) {
        super(HttpStatus.CONFLICT,
              "EMAIL_ALREADY_REGISTERED",
              "An account with this email already exists",
              Map.of("email", email),
              null);
    }
}

public final class InsufficientFundsException extends ApiException {
    public InsufficientFundsException(String accountId, long requested, long available) {
        super(HttpStatus.UNPROCESSABLE_ENTITY,
              "INSUFFICIENT_FUNDS",
              "Account balance cannot cover the requested operation",
              Map.of("accountId", accountId, "requested", requested, "available", available),
              null);
    }
}

One handler to translate them all

GlobalExceptionHandler.java (extended)
@ExceptionHandler(ApiException.class)
public ProblemDetail handleApi(ApiException ex, HttpServletRequest req) {
    log.warn("API exception: code={} status={} path={} ctx={}",
        ex.getCode(), ex.getStatus(), req.getRequestURI(), ex.getContext());

    var problem = ProblemDetail.forStatusAndDetail(ex.getStatus(), ex.getMessage());
    problem.setTitle(ex.getStatus().getReasonPhrase());
    problem.setType(URI.create("https://api.example.com/errors/" + ex.getCode().toLowerCase().replace('_', '-')));
    problem.setProperty("code", ex.getCode());
    problem.setProperty("timestamp", Instant.now());
    problem.setProperty("path", req.getRequestURI());

    if (!ex.getContext().isEmpty()) {
        problem.setProperty("context", ex.getContext());
    }
    return problem;
}

The result: a single handler covers an unbounded family of domain errors. To add a new error type, you create the class — no advice change required.

Sample response

404 Not Found
{
  "type": "https://api.example.com/errors/user-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "No user exists with id 42",
  "code": "USER_NOT_FOUND",
  "timestamp": "2026-05-05T18:24:11.802Z",
  "path": "/api/users/42",
  "context": { "userId": 42 }
}
◆ ◆ ◆
§ 04 — Validation

Bean Validation, gracefully translated

Spring's MethodArgumentNotValidException is thrown when a @Valid request body fails validation. By default Spring Boot renders a wall of error noise. You can do much better.

The request DTO with Lombok

CreateUserRequest.java
package com.example.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

// Java 21 record + bean validation. No Lombok needed for DTOs — records win here.
public record CreateUserRequest(
    @NotBlank @Size(min = 2, max = 50) String name,
    @NotBlank @Email                   String email,
    @Size(min = 8, max = 100)          String password
) { }
▲ Records vs Lombok

For immutable DTOs, prefer Java records. They give you a constructor, accessors, equals, hashCode, and toString with zero annotations. Reserve Lombok for entities, builders, and classes that need mutable state or inheritance.

The validation handler

GlobalExceptionHandler.java (validation)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex,
                                      HttpServletRequest req) {
    var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
        .map(fe -> Map.of(
            "field",   fe.getField(),
            "rejected", String.valueOf(fe.getRejectedValue()),
            "message", Objects.toString(fe.getDefaultMessage(), "Invalid value")
        ))
        .toList();

    log.info("Validation failed for {} — {} field error(s)",
        req.getRequestURI(), fieldErrors.size());

    var problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST,
        "Request body validation failed"
    );
    problem.setTitle("Validation Error");
    problem.setProperty("code", "VALIDATION_FAILED");
    problem.setProperty("errors", fieldErrors);
    problem.setProperty("timestamp", Instant.now());
    problem.setProperty("path", req.getRequestURI());
    return problem;
}

// For @Validated method-parameter validation (e.g. @RequestParam @Min(1) int page)
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraint(ConstraintViolationException ex,
                                      HttpServletRequest req) {
    var violations = ex.getConstraintViolations().stream()
        .map(v -> Map.of(
            "path",    v.getPropertyPath().toString(),
            "message", v.getMessage()
        ))
        .toList();

    var problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST,
        "Request parameter validation failed"
    );
    problem.setProperty("code", "CONSTRAINT_VIOLATION");
    problem.setProperty("violations", violations);
    return problem;
}

Sample 400 response

400 Bad Request
{
  "type": "about:blank",
  "title": "Validation Error",
  "status": 400,
  "detail": "Request body validation failed",
  "code": "VALIDATION_FAILED",
  "errors": [
    { "field": "email",    "rejected": "not-an-email", "message": "must be a well-formed email address" },
    { "field": "password", "rejected": "abc",          "message": "size must be between 8 and 100" }
  ],
  "timestamp": "2026-05-05T18:24:11.802Z",
  "path": "/api/users"
}
◆ ◆ ◆
§ 05 — Observability

Structured logging with MDC

An advice on its own logs each error reactively. The next leap is proactive contextual logging: tagging every log line in a request with a correlation ID, user identity, and route. Combine an OncePerRequestFilter with @ControllerAdvice and you get coherent traces end-to-end.

Step 1 — A request-scoped correlation filter

RequestContextFilter.java
package com.example.observability;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Slf4j
@Component
public class RequestContextFilter extends OncePerRequestFilter {

    public static final String REQUEST_ID = "requestId";
    public static final String USER_ID    = "userId";
    public static final String ROUTE      = "route";

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        var requestId = req.getHeader("X-Request-Id");
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString();
        }

        try {
            MDC.put(REQUEST_ID, requestId);
            MDC.put(ROUTE, req.getMethod() + " " + req.getRequestURI());
            // Populate USER_ID from your security context if available
            res.setHeader("X-Request-Id", requestId);
            chain.doFilter(req, res);
        } finally {
            MDC.clear();   // critical — MDC is thread-local, leaks are dangerous
        }
    }
}

Step 2 — Structured log line in your advice

Because MDC is now populated for every request, every log statement made inside your controller advice (and your services, and your repositories) automatically carries the request ID. The pattern %X{requestId:-} in application.yml renders it.

Sample log output
14:22:08.811 WARN  [b3a4-...-9d2c] c.e.advice.GlobalExceptionHandler - API exception: code=USER_NOT_FOUND status=404 NOT_FOUND path=/api/users/42 ctx={userId=42}
14:22:08.812 INFO  [b3a4-...-9d2c] c.e.observability.AuditAdvice    - audit.outcome user-lookup status=404 latencyMs=12 user=anonymous

Step 3 — Tiered logging by severity

Not every exception deserves ERROR. A simple convention saves your on-call rotation from waking up over a 404:

Log severity helper
private void logByStatus(ApiException ex, HttpServletRequest req) {
    var status = ex.getStatus();
    var msg = "API exception: code={} status={} path={}";

    // Java 21 switch expression — compact, exhaustive, and readable
    switch (status.series()) {
        case CLIENT_ERROR  -> log.warn (msg, ex.getCode(), status, req.getRequestURI());
        case SERVER_ERROR  -> log.error(msg, ex.getCode(), status, req.getRequestURI(), ex);
        default            -> log.info (msg, ex.getCode(), status, req.getRequestURI());
    }
}
⬢ MDC + virtual threads

Java 21's virtual threads work cleanly with SLF4J's MDC as long as you set Spring Boot 3.2+'s default behavior (spring.threads.virtual.enabled=true) and avoid manually reusing platform-thread pools. Always MDC.clear() in a finally.

◆ ◆ ◆
§ 06 — Auditing

A reusable audit pattern

Auditing is a separate concern from error handling — but it pairs beautifully with @ControllerAdvice. The trick is to use a custom annotation, an AOP aspect, and a small advice that emits structured outcome events for both successes and failures.

Step 1 — Define an audit annotation

Audited.java
package com.example.audit;

import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
    /** Action name, e.g. "user.create", "payment.authorize". */
    String value();

    /** Whether to also record the request payload (avoid for PII!). */
    boolean recordPayload() default false;
}

Step 2 — An AOP aspect that records outcomes

AuditAspect.java
package com.example.audit;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {

    private final AuditRecorder recorder;   // your impl: DB, Kafka, log sink, etc.

    @Around("@annotation(audited) || @within(audited)")
    public Object record(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
        var started = Instant.now();
        var action  = audited.value();
        var method  = ((MethodSignature) pjp.getSignature()).getMethod().getName();

        try {
            var result = pjp.proceed();
            recorder.success(AuditEvent.of(action, method, started, audited.recordPayload(), pjp.getArgs()));
            return result;
        } catch (Throwable t) {
            recorder.failure(AuditEvent.of(action, method, started, audited.recordPayload(), pjp.getArgs()), t);
            throw t;   // let the advice translate it
        }
    }
}

Step 3 — A reusable AuditEvent record

AuditEvent.java
package com.example.audit;

import org.slf4j.MDC;

import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;

public record AuditEvent(
    String   action,
    String   method,
    String   requestId,
    String   userId,
    Instant  startedAt,
    Duration latency,
    Object[] payload
) {
    public static AuditEvent of(String action, String method, Instant started,
                                boolean withPayload, Object[] args) {
        return new AuditEvent(
            action,
            method,
            MDC.get("requestId"),
            MDC.get("userId"),
            started,
            Duration.between(started, Instant.now()),
            withPayload ? args : new Object[0]
        );
    }
}

Step 4 — Use it on a controller

UserController with auditing
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService service;

    @PostMapping
    @Audited("user.create")
    public UserDto create(@RequestBody @Valid CreateUserRequest req) {
        return service.create(req);
    }

    @DeleteMapping("/{id}")
    @Audited(value = "user.delete", recordPayload = true)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

Your @ControllerAdvice still handles the exception path. The aspect records the outcome. Both can write to the same audit sink — together they give you a complete log of what happened, why, and how long it took.

⚠ Compliance reminder

If your audit log is a compliance artifact (SOX, HIPAA, GDPR), it must be append-only and tamper-evident. A log file isn't enough — write to an event store, immutable database table, or signed log stream.

◆ ◆ ◆
§ 07 — Cross-cutting state

@ModelAttribute and @InitBinder

Beyond exceptions, advice classes can inject shared state and bind data globally.

Inject a per-request value into every controller

RequestModelAdvice.java
@RestControllerAdvice
public class RequestModelAdvice {

    /** Available as a method argument (or model attribute) in every controller. */
    @ModelAttribute("requestContext")
    public RequestContext requestContext(HttpServletRequest req) {
        return new RequestContext(
            req.getHeader("X-Tenant-Id"),
            req.getLocale(),
            Instant.now()
        );
    }

    /** Trim every incoming String automatically. */
    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.registerCustomEditor(String.class,
            new StringTrimmerEditor(true));   // null when empty
    }
}

public record RequestContext(String tenantId, Locale locale, Instant arrivedAt) { }

Now any controller method can declare @ModelAttribute("requestContext") RequestContext ctx as a parameter and Spring will inject the value populated by the advice.

◆ ◆ ◆
§ 08 — Scoping

Targeted advice with selectors

By default, @ControllerAdvice applies to every controller in the application. That's often wrong. Public APIs and admin APIs typically need different error contracts. @ControllerAdvice exposes selectors:

SelectorEffect
basePackagesApply to controllers in specified packages.
annotationsApply to controllers carrying a specified annotation.
assignableTypesApply to controllers extending/implementing the given type.

Public vs admin advice

Two advices, two scopes
@RestControllerAdvice(basePackages = "com.example.api.public_")
public class PublicApiAdvice {
    // returns sanitized error responses — no stack traces, no internal codes
}

@RestControllerAdvice(basePackages = "com.example.api.admin")
public class AdminApiAdvice {
    // returns verbose error responses — includes correlation IDs, hints, etc.
}

By annotation

Annotation-based scoping
// Marker annotation
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME)
public @interface PublicApi { }

// Advice that only applies to controllers marked @PublicApi
@RestControllerAdvice(annotations = PublicApi.class)
public class PublicApiAdvice { /* ... */ }

@PublicApi
@RestController
@RequestMapping("/api/public/v1")
public class PublicProductController { /* ... */ }

You can also use @Order to control the precedence between multiple advices that match the same exception. Lower order wins.

◆ ◆ ◆
§ 09 — Advanced

Patterns worth stealing

Pattern 1 — Pattern matching on exception types (Java 21)

For aggregator handlers that need to inspect multiple exception shapes, Java 21's pattern matching for switch dramatically cleans things up:

Pattern-matching dispatcher
@ExceptionHandler({ IllegalArgumentException.class,
                    DataAccessException.class,
                    HttpMessageNotReadableException.class })
public ProblemDetail handleCommon(Exception ex, HttpServletRequest req) {
    var problem = switch (ex) {
        case IllegalArgumentException iae ->
            ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, iae.getMessage());

        case HttpMessageNotReadableException hme -> {
            var p = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Malformed JSON request");
            p.setProperty("hint", "Ensure your Content-Type is application/json and the body parses cleanly");
            yield p;
        }

        case DataAccessException dae -> {
            log.error("Database failure on {}", req.getRequestURI(), dae);
            yield ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE,
                "A data store is temporarily unavailable.");
        }

        default -> ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                       "An unexpected error occurred.");
    };
    problem.setProperty("path", req.getRequestURI());
    return problem;
}

Pattern 2 — Extending ResponseEntityExceptionHandler

If you want to fully override Spring MVC's built-in exception responses (for things like HttpRequestMethodNotSupportedException, NoHandlerFoundException, etc.) extend ResponseEntityExceptionHandler and override the protected hooks. You get a uniform error contract for both your exceptions and Spring's framework exceptions.

Extending the framework base
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest req) {

        var problem = ProblemDetail.forStatusAndDetail(status, ex.getMessage());
        problem.setProperty("supportedMethods", ex.getSupportedHttpMethods());
        problem.setProperty("code", "METHOD_NOT_ALLOWED");
        return ResponseEntity.status(status).headers(headers).body(problem);
    }

    // ... your own @ExceptionHandler methods coexist below ...
}

Pattern 3 — Centralized response wrapping

Implement ResponseBodyAdvice to wrap or transform every controller response uniformly — for example, to attach a server timing header or wrap successful responses in an envelope:

EnvelopeAdvice.java
@RestControllerAdvice(basePackages = "com.example.api.public_")
public class EnvelopeAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter rt, Class<? extends HttpMessageConverter<?>> c) {
        return !ProblemDetail.class.isAssignableFrom(rt.getParameterType());
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter mp, MediaType mt,
                                  Class<? extends HttpMessageConverter<?>> c,
                                  ServerHttpRequest req, ServerHttpResponse res) {
        return new ApiEnvelope<>(body, MDC.get("requestId"), Instant.now());
    }

    public record ApiEnvelope<T>(T data, String requestId, Instant timestamp) { }
}

Pattern 4 — i18n-aware messages

Localized error detail
@RestControllerAdvice
@RequiredArgsConstructor
public class LocalizedExceptionHandler {

    private final MessageSource messages;

    @ExceptionHandler(ApiException.class)
    public ProblemDetail handle(ApiException ex, Locale locale) {
        var detail = messages.getMessage(
            "error." + ex.getCode().toLowerCase(),
            ex.getContext().values().toArray(),
            ex.getMessage(),
            locale
        );
        var problem = ProblemDetail.forStatusAndDetail(ex.getStatus(), detail);
        problem.setProperty("code", ex.getCode());
        return problem;
    }
}
◆ ◆ ◆
§ 10 — Toolkit

Lombok annotations to know

Lombok is most valuable when used selectively. Here is the high-leverage subset that pairs naturally with Spring code.

AnnotationWhat it generatesBest used on
@Slf4jA private static final Logger log field.Anywhere you log — controllers, services, advices, aspects.
@RequiredArgsConstructorConstructor that takes every final field.Spring components that use constructor injection.
@Getter / @SetterAccessors for all fields (or a single one if placed on a field).JPA entities, exceptions carrying metadata.
@BuilderA fluent builder + an all-args static factory.Complex DTOs, test fixtures, audit events.
@ValueImmutable class: final fields, getters, equals/hashCode/toString. (Records often replace this.)Pre-Java-16 codebases; immutable value objects with inheritance.
@SneakyThrowsHides checked exceptions inside aspects/handlers.Use sparingly; great for AOP plumbing, dangerous in business code.
@DataCombines getters, setters, equals/hashCode, toString, required-args ctor.Mutable JPA entities; avoid on DTOs (prefer records).

A representative Lombok-powered service

UserService.java
package com.example.user;

import com.example.audit.Audited;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository repository;
    private final PasswordEncoder encoder;

    @Transactional(readOnly = true)
    public UserDto findById(Long id) {
        log.debug("Looking up user id={}", id);
        return repository.findById(id)
            .map(UserDto::fromEntity)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    @Transactional
    @Audited("user.create")
    public UserDto create(CreateUserRequest req) {
        if (repository.existsByEmail(req.email())) {
            throw new DuplicateEmailException(req.email());
        }
        var user = repository.save(User.builder()
            .name(req.name())
            .email(req.email())
            .passwordHash(encoder.encode(req.password()))
            .build());
        log.info("Created user id={} email={}", user.getId(), user.getEmail());
        return UserDto.fromEntity(user);
    }
}
◆ ◆ ◆
§ 11 — Catalog

Use case catalog

A quick-reference map from problem to @ControllerAdvice-shaped solution.

01 · Translate domain errors

Map your typed exceptions (UserNotFoundException, DuplicateEmailException) to RFC 7807 ProblemDetail responses with stable error codes.

02 · Format validation errors

Catch MethodArgumentNotValidException and emit field-by-field error arrays clients can render directly in forms.

03 · Mask sensitive errors

For public APIs, strip stack traces, internal codes, and SQL hints from exception responses while still logging them server-side.

04 · Correlation IDs

Attach a request ID to every response (success or error) so support engineers can trace customer reports through logs.

05 · Translate framework errors

Override Spring's HttpMessageNotReadableException, HttpRequestMethodNotSupportedException, and friends to match your error contract.

06 · Tenant resolution

Use @ModelAttribute on advice to expose a tenant context object every controller can inject.

07 · Global string trimming

Use @InitBinder in advice to trim every incoming String parameter — eliminates a class of validation bugs.

08 · Response envelopes

Implement ResponseBodyAdvice to wrap every successful response in a uniform envelope with metadata.

09 · Differential advice

Use basePackages/annotations to apply different rules to public vs admin APIs.

10 · i18n

Resolve error detail through MessageSource + the request's Locale.

11 · Auditing outcomes

Pair an @Audited aspect with the advice to record both success outcomes and exception outcomes against one event schema.

12 · Rate limit responses

Map RateLimitExceededException to a 429 with Retry-After headers populated from your limiter.

◆ ◆ ◆
§ 12 — Testing

Testing your advice

An advice that isn't tested is a bug factory. @WebMvcTest with MockMvc is the right tool — it boots only the web slice (your controllers + advices), making the tests fast.

UserControllerWebMvcTest.java
@WebMvcTest(UserController.class)
@Import(GlobalExceptionHandler.class)
class UserControllerWebMvcTest {

    @Autowired MockMvc mvc;
    @MockBean   UserService userService;

    @Test
    void notFoundEmitsRfc7807() throws Exception {
        given(userService.findById(42L)).willThrow(new UserNotFoundException(42L));

        mvc.perform(get("/api/users/42"))
           .andExpect(status().isNotFound())
           .andExpect(jsonPath("$.code").value("USER_NOT_FOUND"))
           .andExpect(jsonPath("$.context.userId").value(42))
           .andExpect(jsonPath("$.path").value("/api/users/42"));
    }

    @Test
    void validationErrorReturnsFieldList() throws Exception {
        var bad = """
            { "name": "", "email": "nope", "password": "abc" }
            """;
        mvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(bad))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.errors", hasSize(greaterThanOrEqualTo(2))));
    }
}
◆ ◆ ◆
§ 13 — Closing

Principles to live by

  1. i.  Keep controllers thin. If an exception lives in your domain, throw it. Let the advice translate.
  2. ii.  Use stable, machine-readable error codes (USER_NOT_FOUND, not "user not found"). Clients depend on them.
  3. iii.  Log at the right level. Client errors → WARN or INFO. Server errors → ERROR with the stack trace.
  4. iv.  Never leak internals on public APIs. Stack traces, SQL, file paths — strip or mask.
  5. v.  Always include a correlation ID. Both in logs (MDC) and on the response (header or body).
  6. vi.  Prefer ProblemDetail over hand-rolled error JSON. RFC 7807 is the standard.
  7. vii.  Scope advice carefully. One advice for public APIs, another for admin APIs, ordered explicitly.
  8. viii.  Audit through aspects, not advice. Use @ControllerAdvice for HTTP translation; use AOP for cross-cutting outcome recording.
  9. ix.  Lean on Java 21. Records for DTOs, sealed types for exception families, switch expressions for handler bodies.
  10. x.  Test the advice. @WebMvcTest is fast and gives the right boundary.
▲ The big idea

@ControllerAdvice is your application's seam for cross-cutting HTTP concerns. Build it once, build it well, and every new controller you add inherits a coherent error contract, structured logs, audit trails, and i18n — for free.