Global Exception Handling
Global exception handling standardizes the format of API error responses—clients no longer have to deal with a wide variety of error formats, making debugging and integration more efficient.
1. What You'll Learn
@RestControllerAdvice+@ExceptionHandlerGlobal Exception Handling- Custom Business Exception System:
BusinessException/ResourceNotFoundException/ValidationException - Error Response DTO Design: code / message / timestamp / details
MethodArgumentNotValidExceptionSpecial Handling of Validation Errors- Exception Logging and Error Tracing ID Generation
2. A True Story of a Front-End Developer
(1) Pain Point: Inconsistent error response formats
Bob is a front-end developer who ran into issues while integrating with the OrderFlow API: some endpoints returned a 404 with plain text "Not Found", some returned a 500 with an HTML error page, some returned a business exception {"error": "xxx"}, and others returned {"message": "xxx"}. He had to write different error-handling logic for each endpoint, resulting in try-catch blocks scattered throughout his code—and he often overlooked them, causing the page to go blank.
(2) Solution for @RestControllerAdvice
A unified exception handler ensures that all error responses follow a consistent format:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage(), Instant.now()));
}
}
(3) Revenue
After Alice implemented global exception handling, all error response formats were standardized to {code, message, timestamp, details}, and Bob's front end only needed a single, unified error-handling function, resulting in a fivefold increase in integration efficiency.
3. The @RestControllerAdvice Mechanism
(1) Exception Handling Dispatch Process
graph TD
A[Controller throws Exception] --> B{Spring DispatcherServlet}
B --> C["@RestControllerAdvice<br/>Scans @ExceptionHandler"]
C --> D{Match Exception Type?}
D -->|Yes| E["Execute @ExceptionHandler<br/>Return ErrorResponse"]
D -->|No| F["Spring Default<br/>Error Response"]
E --> G["Client receives<br/>Consistent JSON"]
F --> H["Client receives<br/>Inconsistent response"]
| Note | Purpose | Location |
|---|---|---|
@RestControllerAdvice |
Global Exception Handling Class | On the class |
@ExceptionHandler |
Handling Specified Exception Types | On the Method |
@ResponseStatus |
Specify a response status code | on an exception class or method |
(1) ▶ Example: Basic Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(
ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
"RESOURCE_NOT_FOUND", ex.getMessage(), Instant.now(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(
BusinessException ex) {
ErrorResponse error = new ErrorResponse(
"BUSINESS_ERROR", ex.getMessage(), Instant.now(), null);
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
ErrorResponse error = new ErrorResponse(
"INTERNAL_ERROR", "An unexpected error occurred", Instant.now(), null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
Output:
// Execution Successful
4. Custom Business Exception System
(1) Exception Class Hierarchy
graph TD
A[RuntimeException] --> B[BusinessException<br/>Base business exception]
B --> C[ResourceNotFoundException<br/>404 Not Found]
B --> D[InsufficientStockException<br/>422 Business Rule Violation]
B --> E[OrderStateException<br/>422 Invalid State Transition]
A --> F[ValidationException<br/>400 Bad Request]
(1) ▶ Example: Custom Exception Class
// Base business exception
public class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
// Resource not found
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resource, Long id) {
super("RESOURCE_NOT_FOUND",
resource + " not found with id: " + id);
}
}
// Insufficient stock
public class InsufficientStockException extends BusinessException {
public InsufficientStockException(Long productId, int available, int requested) {
super("INSUFFICIENT_STOCK",
String.format("Product %d: available=%d, requested=%d",
productId, available, requested));
}
}
// Invalid order state
public class OrderStateException extends BusinessException {
public OrderStateException(Long orderId, String current, String target) {
super("INVALID_ORDER_STATE",
String.format("Order %d: cannot transition from %s to %s",
orderId, current, target));
}
}
Output:
// Execution Successful
5. Error Response DTO Design
(1) ErrorResponse Design Principles
(1) ▶ Examples: ErrorResponse and ValidationErrorResponse
public record ErrorResponse(
String code,
String message,
Instant timestamp,
String traceId,
List<FieldError> details
) {
public record FieldError(
String field,
String message,
Object rejectedValue
) {}
}
// Convenience factory methods
public class ErrorResponse {
public static ErrorResponse of(String code, String message, String traceId) {
return new ErrorResponse(code, message, Instant.now(), traceId, null);
}
public static ErrorResponse withDetails(String code, String message,
String traceId, List<FieldError> details) {
return new ErrorResponse(code, message, Instant.now(), traceId, details);
}
}
Output:
// Execution Successful
| Field | Type | Description |
|---|---|---|
code |
String | Error code (machine-readable) |
message |
String | Error message (human-readable) |
timestamp |
Instant | Time of Occurrence |
traceId |
String | Trace ID (Associated Log) |
details |
List | Field-level error details (for validation errors) |
6. Special Handling of Validation Errors
(1) ▶ Example: Handling MethodArgumentNotValidException
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex,
HttpServletRequest request) {
String traceId = generateTraceId();
List<ErrorResponse.FieldError> details = ex.getBindingResult()
.getFieldErrors().stream()
.map(fe -> new ErrorResponse.FieldError(
fe.getField(),
fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value",
fe.getRejectedValue()
))
.toList();
ErrorResponse error = ErrorResponse.withDetails(
"VALIDATION_ERROR",
"Input validation failed",
traceId,
details
);
log.warn("Validation failed [traceId={}]: {}", traceId, details);
return ResponseEntity.badRequest().body(error);
}
}
Output:
// Execution Successful
{
"code": "VALIDATION_ERROR",
"message": "Input validation failed",
"timestamp": "2024-01-15T10:00:00Z",
"traceId": "abc-123-def",
"details": [
{"field": "quantity", "message": "Quantity must be at least 1", "rejectedValue": 0},
{"field": "customerEmail", "message": "Invalid email format", "rejectedValue": "abc"}
]
}
7. Exception Logs and Trace IDs
(1) ▶ Example: A complete exception handler with a tracking ID
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex,
HttpServletRequest request) {
String traceId = generateTraceId();
log.warn("Resource not found [traceId={}]: {}", traceId, ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), traceId));
}
@ExceptionHandler(InsufficientStockException.class)
public ResponseEntity<ErrorResponse> handleInsufficientStock(
InsufficientStockException ex,
HttpServletRequest request) {
String traceId = generateTraceId();
log.warn("Insufficient stock [traceId={}]: {}", traceId, ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), traceId));
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(
BusinessException ex,
HttpServletRequest request) {
String traceId = generateTraceId();
log.warn("Business error [traceId={}]: {}", traceId, ex.getMessage());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), traceId));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(
Exception ex,
HttpServletRequest request) {
String traceId = generateTraceId();
log.error("Unexpected error [traceId={}]", traceId, ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of("INTERNAL_ERROR",
"An unexpected error occurred. TraceId: " + traceId, traceId));
}
private String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
}
Output:
// Execution Successful
| Error Type | HTTP Status Code | Log Level | Description |
|---|---|---|---|
ResourceNotFoundException |
404 | WARN | Resource does not exist; client issue |
InsufficientStockException |
409 | WARN | Business Conflict |
BusinessException |
422 | WARN | Business rule violation |
MethodArgumentNotValidException |
400 | WARN | Input validation failed |
Exception |
500 | ERROR | Unexpected error; needs to be investigated |
8. Comprehensive Example: OrderFlow's Complete Exception Handling System
// ErrorResponse.java
package com.orderflow.exception;
import java.time.Instant;
import java.util.List;
public record ErrorResponse(
String code, String message, Instant timestamp,
String traceId, List<FieldError> details
) {
public record FieldError(String field, String message, Object rejectedValue) {}
public static ErrorResponse of(String code, String message, String traceId) {
return new ErrorResponse(code, message, Instant.now(), traceId, null);
}
public static ErrorResponse withDetails(String code, String message,
String traceId, List<FieldError> details) {
return new ErrorResponse(code, message, Instant.now(), traceId, details);
}
}
// BusinessException hierarchy
public class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String errorCode, String message) {
super(message); this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resource, Long id) {
super("RESOURCE_NOT_FOUND", resource + " not found with id: " + id);
}
}
public class InsufficientStockException extends BusinessException {
public InsufficientStockException(Long productId, int avail, int req) {
super("INSUFFICIENT_STOCK",
"Product " + productId + ": available=" + avail + ", requested=" + req);
}
}
// GlobalExceptionHandler.java
package com.orderflow.exception;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
String tid = tid();
log.warn("[{}] {}", tid, ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), tid));
}
@ExceptionHandler(InsufficientStockException.class)
public ResponseEntity<ErrorResponse> handleStock(InsufficientStockException ex) {
String tid = tid();
log.warn("[{}] {}", tid, ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), tid));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String tid = tid();
List<ErrorResponse.FieldError> details = ex.getBindingResult()
.getFieldErrors().stream()
.map(f -> new ErrorResponse.FieldError(f.getField(),
f.getDefaultMessage() != null ? f.getDefaultMessage() : "", f.getRejectedValue()))
.toList();
log.warn("[{}] Validation failed: {}", tid, details);
return ResponseEntity.badRequest()
.body(ErrorResponse.withDetails("VALIDATION_ERROR", "Validation failed", tid, details));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
String tid = tid();
log.error("[{}] Unexpected error", tid, ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of("INTERNAL_ERROR", "Unexpected error. Ref: " + tid, tid));
}
private String tid() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
}
❓ FAQ
📖 Summary
@RestControllerAdvice+@ExceptionHandlerHandle all Controller exceptions uniformly- Custom exception hierarchy:
BusinessExceptionserves as the base class, with subclasses distinguishing between different business scenarios ErrorResponsecontains five fields:code,message,timestamp,traceId, anddetails- Special handling of validation errors: Extract field-level error details into the
detailsarray - Use WARN and ERROR to distinguish between business exceptions and system exceptions in the exception log
- traceId links responses to logs, making it easier to troubleshoot issues
📝 Exercises
-
Basic Exercise (Difficulty: ⭐): Implement a
GlobalExceptionHandlerforOrderFlowto handleResourceNotFoundExceptionandBusinessException, and return a standardizedErrorResponseformat. -
Advanced Exercise (Difficulty: ⭐⭐): Add a handler for
MethodArgumentNotValidExceptionand extract field-level error details. ImplementtraceIdgeneration and use it in the log. -
Challenge (Difficulty: ⭐⭐⭐): Create a Servlet Filter that generates a traceID at the request entry point and sets it in the MDC, so that all logs automatically include the traceID. The GlobalExceptionHandler reads the traceID from the MDC to enable end-to-end log tracing.



