Exception Handling in Spring Boot
Build a consistent, production-grade error handling layer in Spring Boot using @ControllerAdvice, custom exceptions, and RFC 7807 problem details.
Consistent error handling is one of the most important parts of a production API. Without it, clients receive a mix of Spring’s default error pages, raw stack traces, and inconsistent JSON — all with unhelpful status codes. A well-designed exception layer means every error your API can produce has a predictable shape, a meaningful message, and the right HTTP status code.
Custom Exception Hierarchy
Start by defining a hierarchy of domain exceptions that map cleanly to HTTP status codes. A common base class lets the global handler deal with all of them in one place, while specific subclasses carry semantic meaning in the service layer.
// Base exception for all API errors — carries the HTTP status that should be returned
public abstract class ApiException extends RuntimeException {
private final HttpStatus status;
protected ApiException(String message, HttpStatus status) {
super(message);
this.status = status;
}
protected ApiException(String message, HttpStatus status, Throwable cause) {
super(message, cause);
this.status = status;
}
public HttpStatus getStatus() { return status; }
}
// 404 Not Found — thrown when a requested resource doesn't exist
public class ResourceNotFoundException extends ApiException {
public ResourceNotFoundException(String resourceName, Object id) {
super(resourceName + " not found with id: " + id, HttpStatus.NOT_FOUND);
}
}
// 409 Conflict — thrown when a unique constraint would be violated
public class DuplicateResourceException extends ApiException {
public DuplicateResourceException(String message) {
super(message, HttpStatus.CONFLICT);
}
}
// 400 Bad Request — thrown when a business rule is violated (not a validation error)
public class BusinessRuleException extends ApiException {
public BusinessRuleException(String message) {
super(message, HttpStatus.BAD_REQUEST);
}
}
// 403 Forbidden — thrown when the authenticated user lacks permission for an action
public class ForbiddenException extends ApiException {
public ForbiddenException(String message) {
super(message, HttpStatus.FORBIDDEN);
}
}
Error Response DTO
Define a consistent error response structure and stick to it across all endpoints. Including the request path and timestamp makes errors much easier to correlate with logs. The fieldErrors map is used for validation errors where multiple fields failed.
public record ErrorResponse(
int status,
String error,
String message,
String path,
LocalDateTime timestamp,
Map<String, String> fieldErrors // populated for validation errors; null otherwise
) {
public static ErrorResponse of(HttpStatus status, String message, HttpServletRequest req) {
return new ErrorResponse(status.value(), status.getReasonPhrase(),
message, req.getRequestURI(), LocalDateTime.now(), null);
}
public static ErrorResponse ofValidation(String message,
Map<String, String> fieldErrors, HttpServletRequest req) {
return new ErrorResponse(400, "Bad Request", message,
req.getRequestURI(), LocalDateTime.now(), fieldErrors);
}
}
Global Exception Handler
@RestControllerAdvice registers this class as a global handler that applies to all controllers. Each @ExceptionHandler method catches one exception type and returns a structured response. The order matters — Spring uses the most specific handler available, so the catch-all Exception.class handler only fires when no more specific handler matches.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// Handles all custom domain exceptions — one handler for the whole hierarchy
@ExceptionHandler(ApiException.class)
public ResponseEntity<ErrorResponse> handleApiException(
ApiException ex, HttpServletRequest req) {
log.warn("API error [{}]: {}", ex.getStatus(), ex.getMessage());
return ResponseEntity
.status(ex.getStatus())
.body(ErrorResponse.of(ex.getStatus(), ex.getMessage(), req));
}
// Handles @RequestBody validation failures — multiple field errors in one response
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(
MethodArgumentNotValidException ex, HttpServletRequest req) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
return ErrorResponse.ofValidation("Validation failed", errors, req);
}
// Handles @PathVariable / @RequestParam constraint violations
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolation(
ConstraintViolationException ex, HttpServletRequest req) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getConstraintViolations().forEach(v -> {
String path = v.getPropertyPath().toString();
errors.put(path.substring(path.lastIndexOf('.') + 1), v.getMessage());
});
return ErrorResponse.ofValidation("Validation failed", errors, req);
}
// Handles malformed JSON body — client sent unparseable JSON
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleUnreadable(
HttpMessageNotReadableException ex, HttpServletRequest req) {
return ErrorResponse.of(HttpStatus.BAD_REQUEST, "Malformed JSON request", req);
}
// Handles wrong HTTP method — e.g. client sent POST to a GET-only endpoint
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ErrorResponse handleMethodNotAllowed(
HttpRequestMethodNotSupportedException ex, HttpServletRequest req) {
return ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED, ex.getMessage(), req);
}
// Catch-all — prevents raw stack traces from leaking to clients; always log these
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpected(Exception ex, HttpServletRequest req) {
log.error("Unhandled exception on {} {}", req.getMethod(), req.getRequestURI(), ex);
return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred", req);
}
}
Using Exceptions in Services
With the global handler in place, service methods can simply throw the appropriate exception. The handler translates it to the right HTTP response automatically — no try/catch needed in the controller layer.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public UserResponse findById(Long id) {
// orElseThrow is cleaner than if/else — the handler produces the 404 automatically
return userRepository.findById(id)
.map(UserResponse::from)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
public UserResponse create(CreateUserRequest req) {
if (userRepository.existsByEmail(req.email())) {
throw new DuplicateResourceException("Email already registered: " + req.email());
}
User user = new User(req.name(), req.email(), hashPassword(req.password()));
return UserResponse.from(userRepository.save(user));
}
public void delete(Long id, Long requestingUserId) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
if (!user.getId().equals(requestingUserId)) {
throw new ForbiddenException("You can only delete your own account");
}
userRepository.delete(user);
}
}
Error responses are now automatic and consistent:
# 404 Not Found
curl /api/users/999
# {"status":404,"error":"Not Found","message":"User not found with id: 999","path":"/api/users/999","timestamp":"...","fieldErrors":null}
# 409 Conflict
curl -X POST /api/users -d '{"email":"[email protected]",...}'
# {"status":409,"error":"Conflict","message":"Email already registered: [email protected]",...}
RFC 7807 Problem Details (Spring Boot 3+)
Spring Boot 3 supports the RFC 7807 standard natively. The standard defines a common JSON structure for HTTP error responses, which means any HTTP client that understands RFC 7807 can parse your errors without knowing your specific API.
# Enable Spring Boot's built-in RFC 7807 support
spring.mvc.problemdetails.enabled=true
Or return ProblemDetail from your exception handlers for full control:
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Resource Not Found");
problem.setInstance(URI.create(req.getRequestURI())); // links the error to the specific request
return problem;
}
Response:
{
"type": "about:blank",
"title": "Resource Not Found",
"status": 404,
"detail": "User not found with id: 999",
"instance": "/api/users/999"
}