Q: How do you handle exceptions globally in Spring Boot? @ControllerAdvice, ProblemDetail, RFC 7807.
Answer:
Three layers, each broader scope:
try/catchin handler — local, ugly, boilerplate.@ExceptionHandlerin controller — per-controller.@ControllerAdvice— global across all (or selected) controllers.
@ExceptionHandler (Per-Controller)
@RestController
class OrderController {
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ErrorResponse notFound(OrderNotFoundException e) {
return new ErrorResponse(e.getMessage());
}
}
@ControllerAdvice / @RestControllerAdvice (Global)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ProblemDetail> notFound(EntityNotFoundException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
pd.setType(URI.create("https://api.acme.com/errors/not-found"));
pd.setProperty("timestamp", Instant.now());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> validation(MethodArgumentNotValidException e) {
Map<String, String> errors = e.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage, (a,b)->a));
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setProperty("errors", errors);
return ResponseEntity.badRequest().body(pd);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> fallback(Exception e) {
log.error("Unhandled exception", e);
return ResponseEntity.internalServerError()
.body(ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Internal error"));
}
}
@RestControllerAdvice = @ControllerAdvice + @ResponseBody.
ProblemDetail (Spring 6+, Boot 3+)
RFC 7807 standard error format.
{
"type": "https://api.acme.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Order 42 not found",
"instance": "/api/orders/42",
"timestamp": "2026-04-26T10:00:00Z"
}
Enable RFC 7807 default behavior:
spring:
mvc:
problemdetails:
enabled: true
Built-in Spring exceptions (404, 405, 415, etc.) auto-respond with ProblemDetail.
ResponseStatusException (Quick Throw)
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "order " + id + " not found");
Custom Exception Hierarchy
public abstract class AppException extends RuntimeException {
private final HttpStatus status;
private final String code;
protected AppException(HttpStatus status, String code, String msg) {
super(msg);
this.status = status;
this.code = code;
}
// getters
}
public class OrderNotFoundException extends AppException {
public OrderNotFoundException(long id) {
super(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "order " + id);
}
}
@ExceptionHandler(AppException.class)
public ResponseEntity<ProblemDetail> handle(AppException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(e.getStatus(), e.getMessage());
pd.setProperty("code", e.getCode());
return ResponseEntity.status(e.getStatus()).body(pd);
}
Scope @ControllerAdvice
@RestControllerAdvice(basePackages = "com.acme.api.public")
@RestControllerAdvice(annotations = RestController.class)
@RestControllerAdvice(assignableTypes = {OrderController.class, UserController.class})
ResponseEntityExceptionHandler Base
For full control over Spring's built-in exceptions, extend it:
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders h, HttpStatusCode s, WebRequest r) {
// your custom shape
}
}
Validation Errors — Field-Level Messages
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(MethodArgumentNotValidException e) {
List<Map<String, String>> errors = e.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
.toList();
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setProperty("errors", errors);
return ResponseEntity.badRequest().body(pd);
}
Constraint Violations on Path/Query Params
Different exception type:
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ProblemDetail> constraintViolation(ConstraintViolationException e) {
...
}
Pitfalls
- Logging duplication: don't log + rethrow + log again. Pick one layer.
- Exposing internals: never serialize
e.getMessage()blindly — may leak DB schema, paths. - 404 vs 200 with empty body: pick a convention.
Optional<T>controllers +orElseThrowpattern is common. - Order of advice:
@Ordercontrols precedence when multiple@ControllerAdvicecould handle the same exception. - Async exceptions:
@ExceptionHandlerdoesn't catch errors from inside@Asyncmethods — handle there or viaAsyncUncaughtExceptionHandler.
Best Practice Checklist
- One global
@RestControllerAdvice. ProblemDetailfor response shape.- Domain exceptions → mapped statuses, never raw 500.
- Validation handled separately with field-level breakdown.
- Catch-all
Exception.classlast → log full stack, return generic 500.