Spring BootCustom Exceptions
Spring Boot

Custom Exceptions

A well-designed custom exception hierarchy makes error handling explicit, consistent, and easy to extend. Each exception class carries its own HTTP status, error code, and message — the global handler maps them to responses without conditional logic. This entry covers base exception design, domain exception hierarchies, error codes, exception factories, and best practices.

Base Exception Class

Define a base exception that carries an HTTP status, an application-specific error code, and a human-readable message. All domain exceptions extend this base, which means the global handler needs only one handler method per HTTP status group rather than one per exception class.
Java
// ── Base exception ────────────────────────────────────────────────────
public class ApiException extends RuntimeException {

    private final HttpStatus status;
    private final String     errorCode;

    public ApiException(HttpStatus status, String errorCode, String message) {
        super(message);
        this.status    = status;
        this.errorCode = errorCode;
    }

    public ApiException(HttpStatus status, String errorCode,
                        String message, Throwable cause) {
        super(message, cause);
        this.status    = status;
        this.errorCode = errorCode;
    }

    public HttpStatus getStatus()    { return status; }
    public String     getErrorCode() { return errorCode; }
}

// ── Global handler maps ApiException subtypes in one method ───────────
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(ApiException.class)
    public ResponseEntity<ErrorResponse> handleApiException(
            ApiException ex, HttpServletRequest req) {

        if (ex.getStatus().is5xxServerError()) {
            log.error("[{}] {} {}: {}",
                ex.getErrorCode(), req.getMethod(),
                req.getRequestURI(), ex.getMessage(), ex);
        } else {
            log.warn("[{}] {} {}: {}",
                ex.getErrorCode(), req.getMethod(),
                req.getRequestURI(), ex.getMessage());
        }

        return ResponseEntity.status(ex.getStatus())
            .body(ErrorResponse.of(
                ex.getStatus().value(),
                ex.getStatus().getReasonPhrase(),
                ex.getMessage(),
                ex.getErrorCode(),
                req.getRequestURI()));
    }
}

Domain Exception Hierarchy

Group exceptions by HTTP status. Each status-level class sets the status code; leaf classes set the error code and provide a domain-specific constructor. This structure lets handlers target a whole status group (e.g. all 404s) or a specific leaf exception — whichever is more appropriate.
Java
// ── 404 Not Found ─────────────────────────────────────────────────────
public class NotFoundException extends ApiException {
    public NotFoundException(String errorCode, String message) {
        super(HttpStatus.NOT_FOUND, errorCode, message);
    }
}

public class UserNotFoundException extends NotFoundException {
    public UserNotFoundException(Long id) {
        super("USER_NOT_FOUND", "User not found with id: " + id);
    }
    public UserNotFoundException(String email) {
        super("USER_NOT_FOUND", "User not found with email: " + email);
    }
}

public class OrderNotFoundException extends NotFoundException {
    public OrderNotFoundException(Long id) {
        super("ORDER_NOT_FOUND", "Order not found with id: " + id);
    }
}

public class ProductNotFoundException extends NotFoundException {
    public ProductNotFoundException(Long id) {
        super("PRODUCT_NOT_FOUND", "Product not found with id: " + id);
    }
}

// ── 409 Conflict ──────────────────────────────────────────────────────
public class ConflictException extends ApiException {
    public ConflictException(String errorCode, String message) {
        super(HttpStatus.CONFLICT, errorCode, message);
    }
}

public class DuplicateEmailException extends ConflictException {
    public DuplicateEmailException(String email) {
        super("DUPLICATE_EMAIL",
            "An account with email '" + email + "' already exists");
    }
}

public class DuplicateSkuException extends ConflictException {
    public DuplicateSkuException(String sku) {
        super("DUPLICATE_SKU",
            "A product with SKU '" + sku + "' already exists");
    }
}

// ── 422 Unprocessable Entity — business rule violations ───────────────
public class BusinessRuleException extends ApiException {
    public BusinessRuleException(String errorCode, String message) {
        super(HttpStatus.UNPROCESSABLE_ENTITY, errorCode, message);
    }
}

public class InsufficientStockException extends BusinessRuleException {
    public InsufficientStockException(Long productId, int requested, int available) {
        super("INSUFFICIENT_STOCK", String.format(
            "Product %d has only %d units available but %d were requested",
            productId, available, requested));
    }
}

public class OrderAlreadyShippedException extends BusinessRuleException {
    public OrderAlreadyShippedException(Long orderId) {
        super("ORDER_ALREADY_SHIPPED",
            "Order " + orderId + " has already been shipped and cannot be modified");
    }
}

// ── 403 Forbidden ─────────────────────────────────────────────────────
public class ForbiddenException extends ApiException {
    public ForbiddenException(String errorCode, String message) {
        super(HttpStatus.FORBIDDEN, errorCode, message);
    }
}

public class ResourceOwnershipException extends ForbiddenException {
    public ResourceOwnershipException(String resource, Long id) {
        super("OWNERSHIP_REQUIRED",
            "You do not have permission to modify " + resource + " with id: " + id);
    }
}

Error Code Enum

Centralise error codes in an enum to prevent typos, make them discoverable, and let API documentation tools enumerate all possible codes. The enum carries the HTTP status so the exception constructor stays minimal.
Java
// ── Error code enum ───────────────────────────────────────────────────
@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    // ── User errors ───────────────────────────────────────────────────
    USER_NOT_FOUND      (HttpStatus.NOT_FOUND,            "User not found"),
    USER_DUPLICATE_EMAIL(HttpStatus.CONFLICT,             "Email already registered"),
    USER_INACTIVE       (HttpStatus.UNPROCESSABLE_ENTITY, "User account is inactive"),
    USER_NOT_AUTHORIZED (HttpStatus.FORBIDDEN,            "Insufficient permissions"),

    // ── Order errors ──────────────────────────────────────────────────
    ORDER_NOT_FOUND     (HttpStatus.NOT_FOUND,            "Order not found"),
    ORDER_ALREADY_PAID  (HttpStatus.UNPROCESSABLE_ENTITY, "Order already paid"),
    ORDER_ALREADY_SHIPPED(HttpStatus.UNPROCESSABLE_ENTITY,"Order already shipped"),
    ORDER_CANCELLED     (HttpStatus.UNPROCESSABLE_ENTITY, "Order has been cancelled"),

    // ── Product errors ────────────────────────────────────────────────
    PRODUCT_NOT_FOUND   (HttpStatus.NOT_FOUND,            "Product not found"),
    PRODUCT_OUT_OF_STOCK(HttpStatus.UNPROCESSABLE_ENTITY, "Product is out of stock"),
    DUPLICATE_SKU       (HttpStatus.CONFLICT,             "SKU already exists"),

    // ── Payment errors ────────────────────────────────────────────────
    PAYMENT_DECLINED    (HttpStatus.PAYMENT_REQUIRED,     "Payment declined"),
    INSUFFICIENT_FUNDS  (HttpStatus.UNPROCESSABLE_ENTITY, "Insufficient funds"),

    // ── Generic ───────────────────────────────────────────────────────
    INTERNAL_ERROR      (HttpStatus.INTERNAL_SERVER_ERROR,"An unexpected error occurred");

    private final HttpStatus status;
    private final String     defaultMessage;
}

// ── Enum-based base exception ─────────────────────────────────────────
public class AppException extends RuntimeException {

    private final ErrorCode errorCode;

    public AppException(ErrorCode errorCode) {
        super(errorCode.getDefaultMessage());
        this.errorCode = errorCode;
    }

    public AppException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public ErrorCode  getErrorCode() { return errorCode; }
    public HttpStatus getStatus()    { return errorCode.getStatus(); }
}

// ── Usage ─────────────────────────────────────────────────────────────
throw new AppException(ErrorCode.USER_NOT_FOUND,
    "User not found with id: " + id);

throw new AppException(ErrorCode.INSUFFICIENT_FUNDS,
    "Balance is $12.00 but order total is $49.99");

Exception Factories

Static factory methods on a utility class centralise exception message construction. Controllers and services call a one-liner factory method instead of constructing exception messages inline. This makes messages consistent, testable, and easy to change without hunting through every call site.
Java
// ── Exception factory ────────────────────────────────────────────────
public final class Exceptions {

    private Exceptions() {}

    // ── Not Found ─────────────────────────────────────────────────────
    public static UserNotFoundException userNotFound(Long id) {
        return new UserNotFoundException(id);
    }

    public static UserNotFoundException userNotFound(String email) {
        return new UserNotFoundException(email);
    }

    public static OrderNotFoundException orderNotFound(Long id) {
        return new OrderNotFoundException(id);
    }

    public static ProductNotFoundException productNotFound(Long id) {
        return new ProductNotFoundException(id);
    }

    // ── Conflict ──────────────────────────────────────────────────────
    public static DuplicateEmailException duplicateEmail(String email) {
        return new DuplicateEmailException(email);
    }

    // ── Business Rule ─────────────────────────────────────────────────
    public static InsufficientStockException insufficientStock(
            Long productId, int requested, int available) {
        return new InsufficientStockException(productId, requested, available);
    }

    public static OrderAlreadyShippedException orderAlreadyShipped(Long orderId) {
        return new OrderAlreadyShippedException(orderId);
    }

    // ── Forbidden ─────────────────────────────────────────────────────
    public static ResourceOwnershipException notOwner(
            String resource, Long id) {
        return new ResourceOwnershipException(resource, id);
    }
}

// ── Clean call sites ──────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository    orderRepo;
    private final UserRepository     userRepo;
    private final ProductRepository  productRepo;

    public OrderResponse ship(Long orderId, Long requestingUserId) {
        Order order = orderRepo.findById(orderId)
            .orElseThrow(() -> Exceptions.orderNotFound(orderId));

        if (!order.getOwnerId().equals(requestingUserId)) {
            throw Exceptions.notOwner("order", orderId);
        }

        if (order.isShipped()) {
            throw Exceptions.orderAlreadyShipped(orderId);
        }

        return OrderResponse.from(orderRepo.save(order.ship()));
    }
}

Wrapping Third-Party Exceptions

Third-party and JPA exceptions — DataIntegrityViolationException, OptimisticLockingFailureException, and others — leak infrastructure details to callers. Catch them in the service layer or in a dedicated @ExceptionHandler and wrap them in domain exceptions before they reach the response.
Java
// ── Service layer wrapping ────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepo;

    public UserResponse create(CreateUserRequest request) {
        try {
            User saved = userRepo.save(User.from(request));
            return UserResponse.from(saved);
        } catch (DataIntegrityViolationException ex) {
            // Unique constraint on email column
            if (ex.getMessage() != null &&
                    ex.getMessage().contains("users_email_key")) {
                throw new DuplicateEmailException(request.email());
            }
            throw ex;   // re-throw unknown constraint violations
        }
    }

    public UserResponse update(Long id, UpdateUserRequest request) {
        try {
            User user = userRepo.findById(id)
                .orElseThrow(() -> Exceptions.userNotFound(id));
            user.apply(request);
            return UserResponse.from(userRepo.save(user));
        } catch (OptimisticLockingFailureException ex) {
            throw new ConflictException("OPTIMISTIC_LOCK",
                "The record was modified by another request — please retry");
        }
    }
}

// ── @ExceptionHandler wrapping JPA exceptions globally ────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleDataIntegrity(
            DataIntegrityViolationException ex, HttpServletRequest req) {
        // Fallback: surface a generic conflict if service did not wrap it
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ErrorResponse.of(409, "Conflict",
                "A data integrity constraint was violated",
                "DATA_INTEGRITY", req.getRequestURI()));
    }

    @ExceptionHandler(OptimisticLockingFailureException.class)
    public ResponseEntity<ErrorResponse> handleOptimisticLock(
            OptimisticLockingFailureException ex, HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ErrorResponse.of(409, "Conflict",
                "The record was modified concurrently — please retry",
                "OPTIMISTIC_LOCK", req.getRequestURI()));
    }

    @ExceptionHandler(TransactionSystemException.class)
    public ResponseEntity<ErrorResponse> handleTransactionSystem(
            TransactionSystemException ex, HttpServletRequest req) {
        Throwable cause = ex.getRootCause();
        if (cause instanceof ConstraintViolationException cve) {
            Map<String, String> errors = cve.getConstraintViolations().stream()
                .collect(Collectors.toMap(
                    cv -> cv.getPropertyPath().toString(),
                    ConstraintViolation::getMessage,
                    (a, b) -> a));
            return ResponseEntity.badRequest()
                .body(ErrorResponse.ofFields(400, "Validation Failed",
                    "Entity validation failed",
                    req.getRequestURI(), errors));
        }
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Internal Server Error",
                "Transaction failed", "TX_ERROR", req.getRequestURI()));
    }
}