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.
@RestControllerAdvice = @ControllerAdvice + @ResponseBody. Use it for REST APIs (the common case). Use plain @ControllerAdvice when you need to return view names from MVC handlers.
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)
<?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)
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
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"
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.
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
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
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.
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.
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
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
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
@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
{
"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 }
}
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
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
) { }
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
@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
{
"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"
}
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
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.
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:
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());
}
}
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.
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
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
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
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
@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.
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.
@ModelAttribute and @InitBinder
Beyond exceptions, advice classes can inject shared state and bind data globally.
Inject a per-request value into every controller
@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.
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:
| Selector | Effect |
|---|---|
basePackages | Apply to controllers in specified packages. |
annotations | Apply to controllers carrying a specified annotation. |
assignableTypes | Apply to controllers extending/implementing the given type. |
Public vs admin advice
@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
// 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.
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:
@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.
@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:
@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
@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;
}
}
Lombok annotations to know
Lombok is most valuable when used selectively. Here is the high-leverage subset that pairs naturally with Spring code.
| Annotation | What it generates | Best used on |
|---|---|---|
@Slf4j | A private static final Logger log field. | Anywhere you log — controllers, services, advices, aspects. |
@RequiredArgsConstructor | Constructor that takes every final field. | Spring components that use constructor injection. |
@Getter / @Setter | Accessors for all fields (or a single one if placed on a field). | JPA entities, exceptions carrying metadata. |
@Builder | A fluent builder + an all-args static factory. | Complex DTOs, test fixtures, audit events. |
@Value | Immutable class: final fields, getters, equals/hashCode/toString. (Records often replace this.) | Pre-Java-16 codebases; immutable value objects with inheritance. |
@SneakyThrows | Hides checked exceptions inside aspects/handlers. | Use sparingly; great for AOP plumbing, dangerous in business code. |
@Data | Combines getters, setters, equals/hashCode, toString, required-args ctor. | Mutable JPA entities; avoid on DTOs (prefer records). |
A representative Lombok-powered service
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);
}
}
Use case catalog
A quick-reference map from problem to @ControllerAdvice-shaped solution.
Map your typed exceptions (UserNotFoundException, DuplicateEmailException) to RFC 7807 ProblemDetail responses with stable error codes.
Catch MethodArgumentNotValidException and emit field-by-field error arrays clients can render directly in forms.
For public APIs, strip stack traces, internal codes, and SQL hints from exception responses while still logging them server-side.
Attach a request ID to every response (success or error) so support engineers can trace customer reports through logs.
Override Spring's HttpMessageNotReadableException, HttpRequestMethodNotSupportedException, and friends to match your error contract.
Use @ModelAttribute on advice to expose a tenant context object every controller can inject.
Use @InitBinder in advice to trim every incoming String parameter — eliminates a class of validation bugs.
Implement ResponseBodyAdvice to wrap every successful response in a uniform envelope with metadata.
Use basePackages/annotations to apply different rules to public vs admin APIs.
Resolve error detail through MessageSource + the request's Locale.
Pair an @Audited aspect with the advice to record both success outcomes and exception outcomes against one event schema.
Map RateLimitExceededException to a 429 with Retry-After headers populated from your limiter.
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.
@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))));
}
}
Principles to live by
- i. Keep controllers thin. If an exception lives in your domain, throw it. Let the advice translate.
- ii. Use stable, machine-readable error codes (
USER_NOT_FOUND, not "user not found"). Clients depend on them. - iii. Log at the right level. Client errors →
WARNorINFO. Server errors →ERRORwith the stack trace. - iv. Never leak internals on public APIs. Stack traces, SQL, file paths — strip or mask.
- v. Always include a correlation ID. Both in logs (MDC) and on the response (header or body).
- vi. Prefer
ProblemDetailover hand-rolled error JSON. RFC 7807 is the standard. - vii. Scope advice carefully. One advice for public APIs, another for admin APIs, ordered explicitly.
- viii. Audit through aspects, not advice. Use
@ControllerAdvicefor HTTP translation; use AOP for cross-cutting outcome recording. - ix. Lean on Java 21. Records for DTOs, sealed types for exception families, switch expressions for handler bodies.
- x. Test the advice.
@WebMvcTestis fast and gives the right boundary.
@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.