Implementing Robust Global Exception Handling with Unified Response Objects in Spring Boot
A centralized mechanism for error propagation ensures consistent client feedback without cluttering individual controllers. The solution relies on four pillars: defining standard business states, wrapping payloads into a generic DTO, utilizing custom runtime exceptions for expected failures, and intercepting exceptions via @RestControllerAdvice at the controller boundary.
Leveraging ResponseEntity
While custom wrappers handle semantic data, ResponseEntity manages the low-level HTTP protocol details such as headers and status codes. It allows programmatic control over the response envelope using a fluent API.
// Returning a custom map payload with specific HTTP status
ResponseEntity<Map<String, Object>> response = ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Map.of("err", "Invalid input", "code", 400));
Key HTTP ranges include:
- 2xx: Success (e.g., 200 OK, 201 Created).
- 4xx: Client errors (e.g., 401 Unauthorized, 403 Forbidden, 405 Method Not Allowed).
- 5xx: Server faults (e.g., 500 Internal Server Error).
Internally, the builder pattern allows chaining methods like .header() or .contentType() before finalizing the object with .body().
Standardizing the Payload
To normalize responses across all endpoints, create a generic class that includes status information, an error message, and the actual data payload. Adding a timestamp aids debugging when reviewing server logs.
public class ApiResponse<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
// Static factory for successful operations
public static <T> ApiResponse<T> success(T payload) {
return create(200, "Success", payload);
}
// Static factory for failure scenarios
public static <T> ApiResponse<T> fail(Integer status, String msg) {
return create(status, msg, null);
}
private static <T> ApiResponse<T> create(Integer c, String m, T d) {
ApiResponse<T> resp = new ApiResponse<>();
resp.timestamp = System.currentTimeMillis();
resp.code = c;
resp.message = m;
resp.data = d;
return resp;
}
}
Centralized Exception Handling
Place this class near your application entry point. Exception resolution order matters: most specific handlers must appear before general catchers to ensure priority handling.
@Slf4j
@RestControllerAdvice
public class GlobalErrorHandler {
// Priority 1: Handle custom business rule violations
@ExceptionHandler(BusinessRuleViolation.class)
public ApiResponse<?> handleBusinessError(BusinessRuleViolation e) {
log.warn("Business violation detected: {}", e.getMessage());
return ApiResponse.fail(e.getCode(), e.getMessage());
}
// Priority 2: Handle input validation failures from JSR-303 annotations
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<?> handleValidationFail(MethodArgumentNotValidException e) {
String detail = e.getBindingResult()
.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ApiResponse.fail(422, detail);
}
// Priority 3: Handle missing resources (404 equivalent)
@ExceptionHandler(NoHandlerFoundException.class)
public ApiResponse<?> handleNotFound(NoHandlerFoundException e) {
return ApiResponse.fail(404, "Requested resource not found");
}
// Priority 4: Catch-all for unexpected system crashes
@ExceptionHandler(Exception.class)
public ApiResponse<?> handleSystemCrash(Exception t) {
log.error("Critical system failure occurred", t);
return ApiResponse.fail(500, "Internal server error occurred");
}
}
This setup eliminates repetitive try-catch blocks while ensuring every error maps to a safe, structured JSON response suitable for frontend consumpsion.