Table of Contents
- Introduction to Custom Exceptions
- When to Create Custom Exceptions
- Exception Hierarchy in Java
- Creating Checked Custom Exceptions
- Creating Unchecked Custom Exceptions
- Best Practices for Custom Exceptions
- Advanced Custom Exception Features
- Real-World Examples
- Common Pitfalls and Anti-patterns
Introduction to Custom Exceptions
Custom exceptions are user-defined exception classes that extend Java's built-in exception classes. They allow you to create application-specific exceptions that convey meaningful error information tailored to your domain.
Why Use Custom Exceptions?
- Domain-specific error information
- Better error categorization
- Improved code readability
- Consistent error handling
- Enhanced debugging information
When to Create Custom Exceptions
Appropriate Scenarios
- Business rule violations
- Domain-specific error conditions
- API error reporting
- Framework-specific errors
- When built-in exceptions are insufficient
When NOT to Use Custom Exceptions
- For general programming errors (use built-in exceptions)
- When existing exceptions adequately describe the error
- For flow control (exceptions should be exceptional)
Exception Hierarchy in Java
java.lang.Object ↳ java.lang.Throwable ↳ java.lang.Exception (Checked) ↳ java.lang.RuntimeException (Unchecked) ↳ Other Checked Exceptions ↳ java.lang.Error (Unchecked)
Checked vs Unchecked Exceptions
- Checked Exceptions: Must be declared or handled (compile-time checking)
- Unchecked Exceptions: RuntimeExceptions and Errors (no compile-time checking)
Creating Checked Custom Exceptions
Checked exceptions extend Exception class and represent recoverable conditions.
Basic Checked Exception
/**
* Custom checked exception for invalid user input
*/
public class InvalidInputException extends Exception {
// Default constructor
public InvalidInputException() {
super();
}
// Constructor with message
public InvalidInputException(String message) {
super(message);
}
// Constructor with message and cause
public InvalidInputException(String message, Throwable cause) {
super(message, cause);
}
// Constructor with cause only
public InvalidInputException(Throwable cause) {
super(cause);
}
}
Domain-Specific Checked Exception
/**
* Exception thrown when a bank account has insufficient funds
*/
public class InsufficientFundsException extends Exception {
private final String accountNumber;
private final double currentBalance;
private final double requiredAmount;
public InsufficientFundsException(String accountNumber, double currentBalance, double requiredAmount) {
super(String.format(
"Account %s has insufficient funds. Current: $%.2f, Required: $%.2f, Shortage: $%.2f",
accountNumber, currentBalance, requiredAmount, (requiredAmount - currentBalance)
));
this.accountNumber = accountNumber;
this.currentBalance = currentBalance;
this.requiredAmount = requiredAmount;
}
// Getters for additional context
public String getAccountNumber() {
return accountNumber;
}
public double getCurrentBalance() {
return currentBalance;
}
public double getRequiredAmount() {
return requiredAmount;
}
public double getShortageAmount() {
return requiredAmount - currentBalance;
}
}
Usage Example
public class BankAccount {
private String accountNumber;
private double balance;
public void withdraw(double amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException(accountNumber, balance, amount);
}
balance -= amount;
}
// Usage
public static void main(String[] args) {
BankAccount account = new BankAccount("12345", 1000.0);
try {
account.withdraw(1500.0);
} catch (InsufficientFundsException e) {
System.err.println("Withdrawal failed: " + e.getMessage());
System.out.println("Account: " + e.getAccountNumber());
System.out.println("Shortage: $" + e.getShortageAmount());
// Offer alternatives
offerOverdraftProtection(e);
}
}
private static void offerOverdraftProtection(InsufficientFundsException e) {
if (e.getShortageAmount() <= 500.0) {
System.out.println("Would you like to use overdraft protection?");
}
}
}
Creating Unchecked Custom Exceptions
Unchecked exceptions extend RuntimeException and represent programming errors or unrecoverable conditions.
Basic Unchecked Exception
/**
* Custom unchecked exception for configuration errors
*/
public class ConfigurationException extends RuntimeException {
public ConfigurationException() {
super();
}
public ConfigurationException(String message) {
super(message);
}
public ConfigurationException(String message, Throwable cause) {
super(message, cause);
}
public ConfigurationException(Throwable cause) {
super(cause);
}
}
Validation Unchecked Exception
/**
* Exception for data validation failures
*/
public class ValidationException extends RuntimeException {
private final String fieldName;
private final Object invalidValue;
private final String validationRule;
public ValidationException(String fieldName, Object invalidValue, String validationRule) {
super(String.format(
"Validation failed for field '%s': value '%s' violates rule '%s'",
fieldName, invalidValue, validationRule
));
this.fieldName = fieldName;
this.invalidValue = invalidValue;
this.validationRule = validationRule;
}
// Getters
public String getFieldName() { return fieldName; }
public Object getInvalidValue() { return invalidValue; }
public String getValidationRule() { return validationRule; }
// Static factory methods for common validation errors
public static ValidationException requiredField(String fieldName) {
return new ValidationException(fieldName, null, "Field is required");
}
public static ValidationException invalidEmail(String fieldName, String value) {
return new ValidationException(fieldName, value, "Must be a valid email address");
}
public static ValidationException outOfRange(String fieldName, Number value, Number min, Number max) {
return new ValidationException(fieldName, value,
String.format("Must be between %s and %s", min, max));
}
}
Usage Example
public class UserValidator {
public void validateUser(User user) {
if (user == null) {
throw ValidationException.requiredField("user");
}
if (user.getEmail() == null || user.getEmail().trim().isEmpty()) {
throw ValidationException.requiredField("email");
}
if (!isValidEmail(user.getEmail())) {
throw ValidationException.invalidEmail("email", user.getEmail());
}
if (user.getAge() < 18 || user.getAge() > 120) {
throw ValidationException.outOfRange("age", user.getAge(), 18, 120);
}
}
private boolean isValidEmail(String email) {
return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
}
}
Best Practices for Custom Exceptions
1. Follow Naming Conventions
// GOOD - Descriptive names ending with "Exception"
public class PaymentProcessingException extends Exception {}
public class UserNotFoundException extends RuntimeException {}
// BAD - Unclear names
public class PaymentError extends Exception {} // Too vague
public class BadUser extends RuntimeException {} // Not descriptive
2. Provide Useful Constructors
public class DatabaseConnectionException extends Exception {
// Always provide these four standard constructors
public DatabaseConnectionException() {}
public DatabaseConnectionException(String message) {}
public DatabaseConnectionException(String message, Throwable cause) {}
public DatabaseConnectionException(Throwable cause) {}
}
3. Add Contextual Information
public class OrderProcessingException extends Exception {
private final String orderId;
private final String customerId;
private final OrderStatus currentStatus;
public OrderProcessingException(String orderId, String customerId,
OrderStatus currentStatus, String message) {
super(String.format("Order %s for customer %s failed: %s",
orderId, customerId, message));
this.orderId = orderId;
this.customerId = customerId;
this.currentStatus = currentStatus;
}
// Getters for context
public String getOrderId() { return orderId; }
public String getCustomerId() { return customerId; }
public OrderStatus getCurrentStatus() { return currentStatus; }
}
4. Use JavaDoc Documentation
/**
* Thrown when an attempt is made to perform an operation on a user
* that doesn't exist in the system.
*
* @author Your Name
* @version 1.0
* @since 2024
*/
public class UserNotFoundException extends RuntimeException {
private final String userId;
/**
* Constructs a new UserNotFoundException with the specified user ID.
*
* @param userId the ID of the user that was not found
*/
public UserNotFoundException(String userId) {
super("User not found with ID: " + userId);
this.userId = userId;
}
/**
* Returns the ID of the user that was not found.
*
* @return the user ID
*/
public String getUserId() {
return userId;
}
}
Advanced Custom Exception Features
1. Exception Chaining with Cause
public class ServiceLayerException extends Exception {
private final String serviceName;
private final String operation;
public ServiceLayerException(String serviceName, String operation,
String message, Throwable rootCause) {
super(String.format("Service '%s' operation '%s' failed: %s",
serviceName, operation, message), rootCause);
this.serviceName = serviceName;
this.operation = operation;
}
public String getServiceName() { return serviceName; }
public String getOperation() { return operation; }
// Helper method to extract root cause
public Throwable getRootCause() {
Throwable cause = getCause();
while (cause != null && cause.getCause() != null) {
cause = cause.getCause();
}
return cause;
}
}
2. Hierarchical Custom Exceptions
// Base exception for all payment-related errors
public abstract class PaymentException extends Exception {
private final String transactionId;
private final BigDecimal amount;
public PaymentException(String transactionId, BigDecimal amount, String message) {
super(message);
this.transactionId = transactionId;
this.amount = amount;
}
public PaymentException(String transactionId, BigDecimal amount, String message, Throwable cause) {
super(message, cause);
this.transactionId = transactionId;
this.amount = amount;
}
public String getTransactionId() { return transactionId; }
public BigDecimal getAmount() { return amount; }
}
// Specific payment exceptions
public class CreditCardDeclinedException extends PaymentException {
private final String declineReason;
public CreditCardDeclinedException(String transactionId, BigDecimal amount,
String declineReason) {
super(transactionId, amount,
String.format("Credit card declined: %s", declineReason));
this.declineReason = declineReason;
}
public String getDeclineReason() { return declineReason; }
}
public class InvalidPaymentMethodException extends PaymentException {
private final String paymentMethod;
public InvalidPaymentMethodException(String transactionId, BigDecimal amount,
String paymentMethod) {
super(transactionId, amount,
String.format("Invalid payment method: %s", paymentMethod));
this.paymentMethod = paymentMethod;
}
public String getPaymentMethod() { return paymentMethod; }
}
public class PaymentGatewayException extends PaymentException {
private final String gatewayName;
private final int errorCode;
public PaymentGatewayException(String transactionId, BigDecimal amount,
String gatewayName, int errorCode, String message) {
super(transactionId, amount,
String.format("Gateway '%s' error %d: %s", gatewayName, errorCode, message));
this.gatewayName = gatewayName;
this.errorCode = errorCode;
}
public String getGatewayName() { return gatewayName; }
public int getErrorCode() { return errorCode; }
}
3. Serializable Exceptions with Versioning
public class BusinessException extends Exception implements Serializable {
private static final long serialVersionUID = 1L; // Version for serialization
private final String errorCode;
private final Map<String, Object> context;
public BusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
this.context = new HashMap<>();
}
public BusinessException(String errorCode, String message, Map<String, Object> context) {
super(message);
this.errorCode = errorCode;
this.context = new HashMap<>(context); // Defensive copy
}
public String getErrorCode() { return errorCode; }
public Map<String, Object> getContext() { return new HashMap<>(context); }
public void addContext(String key, Object value) {
context.put(key, value);
}
public Object getContextValue(String key) {
return context.get(key);
}
}
Real-World Examples
Example 1: E-Commerce Application
// E-commerce custom exceptions hierarchy
public abstract class ECommerceException extends RuntimeException {
private final String customerId;
private final String orderId;
public ECommerceException(String customerId, String orderId, String message) {
super(message);
this.customerId = customerId;
this.orderId = orderId;
}
public String getCustomerId() { return customerId; }
public String getOrderId() { return orderId; }
}
public class OutOfStockException extends ECommerceException {
private final String productId;
private final int requestedQuantity;
private final int availableQuantity;
public OutOfStockException(String customerId, String orderId, String productId,
int requestedQuantity, int availableQuantity) {
super(customerId, orderId,
String.format("Product %s out of stock. Requested: %d, Available: %d",
productId, requestedQuantity, availableQuantity));
this.productId = productId;
this.requestedQuantity = requestedQuantity;
this.availableQuantity = availableQuantity;
}
// Getters and business methods
public String getProductId() { return productId; }
public int getRequestedQuantity() { return requestedQuantity; }
public int getAvailableQuantity() { return availableQuantity; }
public boolean canPartialFulfill() {
return availableQuantity > 0;
}
public int getFulfillableQuantity() {
return Math.min(requestedQuantity, availableQuantity);
}
}
public class PriceChangedException extends ECommerceException {
private final String productId;
private final BigDecimal originalPrice;
private final BigDecimal newPrice;
public PriceChangedException(String customerId, String orderId, String productId,
BigDecimal originalPrice, BigDecimal newPrice) {
super(customerId, orderId,
String.format("Price changed for product %s. Original: $%.2f, New: $%.2f",
productId, originalPrice, newPrice));
this.productId = productId;
this.originalPrice = originalPrice;
this.newPrice = newPrice;
}
public String getProductId() { return productId; }
public BigDecimal getOriginalPrice() { return originalPrice; }
public BigDecimal getNewPrice() { return newPrice; }
public BigDecimal getPriceDifference() {
return newPrice.subtract(originalPrice);
}
}
Example 2: API Framework Exceptions
// REST API custom exceptions
public abstract class ApiException extends RuntimeException {
private final int httpStatusCode;
private final String errorCode;
private final Map<String, Object> details;
public ApiException(int httpStatusCode, String errorCode, String message) {
super(message);
this.httpStatusCode = httpStatusCode;
this.errorCode = errorCode;
this.details = new HashMap<>();
}
public int getHttpStatusCode() { return httpStatusCode; }
public String getErrorCode() { return errorCode; }
public Map<String, Object> getDetails() { return new HashMap<>(details); }
public void addDetail(String key, Object value) {
details.put(key, value);
}
public ApiResponse toApiResponse() {
return new ApiResponse(errorCode, getMessage(), details);
}
}
public class ResourceNotFoundException extends ApiException {
public ResourceNotFoundException(String resourceType, String resourceId) {
super(404, "RESOURCE_NOT_FOUND",
String.format("%s with ID '%s' not found", resourceType, resourceId));
addDetail("resourceType", resourceType);
addDetail("resourceId", resourceId);
}
}
public class ValidationFailedException extends ApiException {
public ValidationFailedException(List<FieldError> fieldErrors) {
super(400, "VALIDATION_FAILED", "Request validation failed");
addDetail("fieldErrors", fieldErrors);
addDetail("errorCount", fieldErrors.size());
}
}
public class AccessDeniedException extends ApiException {
public AccessDeniedException(String resource, String action) {
super(403, "ACCESS_DENIED",
String.format("Access denied for %s on %s", action, resource));
addDetail("resource", resource);
addDetail("action", action);
}
}
// Usage in REST controller
@RestController
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiResponse> handleApiException(ApiException e) {
return ResponseEntity.status(e.getHttpStatusCode())
.body(e.toApiResponse());
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse> handleNotFound(ResourceNotFoundException e) {
return ResponseEntity.status(404).body(e.toApiResponse());
}
}
Example 3: Financial Trading System
// Trading system exceptions
public abstract class TradingException extends Exception {
private final String symbol;
private final BigDecimal price;
private final int quantity;
public TradingException(String symbol, BigDecimal price, int quantity, String message) {
super(message);
this.symbol = symbol;
this.price = price;
this.quantity = quantity;
}
public String getSymbol() { return symbol; }
public BigDecimal getPrice() { return price; }
public int getQuantity() { return quantity; }
public BigDecimal getTotalValue() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}
public class InsufficientLiquidityException extends TradingException {
private final BigDecimal availableLiquidity;
public InsufficientLiquidityException(String symbol, BigDecimal price, int quantity,
BigDecimal availableLiquidity) {
super(symbol, price, quantity,
String.format("Insufficient liquidity for %s. Required: $%.2f, Available: $%.2f",
symbol, price.multiply(BigDecimal.valueOf(quantity)), availableLiquidity));
this.availableLiquidity = availableLiquidity;
}
public BigDecimal getAvailableLiquidity() { return availableLiquidity; }
public BigDecimal getLiquidityShortfall() {
return getTotalValue().subtract(availableLiquidity);
}
}
public class MarketClosedException extends TradingException {
private final LocalDateTime marketOpenTime;
public MarketClosedException(String symbol, BigDecimal price, int quantity,
LocalDateTime marketOpenTime) {
super(symbol, price, quantity,
String.format("Market closed for %s. Opens at %s",
symbol, marketOpenTime.format(DateTimeFormatter.ISO_LOCAL_TIME)));
this.marketOpenTime = marketOpenTime;
}
public LocalDateTime getMarketOpenTime() { return marketOpenTime; }
public Duration getTimeUntilOpen() {
return Duration.between(LocalDateTime.now(), marketOpenTime);
}
}
public class PriceSlippageException extends TradingException {
private final BigDecimal expectedPrice;
private final BigDecimal actualPrice;
private final BigDecimal slippagePercent;
public PriceSlippageException(String symbol, BigDecimal expectedPrice,
BigDecimal actualPrice, int quantity) {
super(symbol, actualPrice, quantity,
String.format("Price slippage for %s. Expected: $%.2f, Actual: $%.2f, Slippage: %.2f%%",
symbol, expectedPrice, actualPrice,
calculateSlippagePercent(expectedPrice, actualPrice)));
this.expectedPrice = expectedPrice;
this.actualPrice = actualPrice;
this.slippagePercent = calculateSlippagePercent(expectedPrice, actualPrice);
}
private static BigDecimal calculateSlippagePercent(BigDecimal expected, BigDecimal actual) {
return actual.subtract(expected)
.divide(expected, 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
}
public BigDecimal getExpectedPrice() { return expectedPrice; }
public BigDecimal getActualPrice() { return actualPrice; }
public BigDecimal getSlippagePercent() { return slippagePercent; }
public BigDecimal getSlippageAmount() {
return actualPrice.subtract(expectedPrice).multiply(BigDecimal.valueOf(quantity));
}
}
Common Pitfalls and Anti-patterns
1. Empty Catch Blocks
// BAD - Silent failure
try {
processOrder();
} catch (OrderProcessingException e) {
// Empty! Exception swallowed
}
// GOOD - Proper handling
try {
processOrder();
} catch (OrderProcessingException e) {
logger.error("Order processing failed", e);
notifyUser(e.getMessage());
rollbackTransaction();
}
2. Overly Broad Exception Types
// BAD - Too generic
public class MyAppException extends Exception {
// No specific information
}
// GOOD - Specific and meaningful
public class UserRegistrationException extends Exception {
private final String username;
private final ValidationError[] errors;
// Specific context and information
}
3. Using Exceptions for Control Flow
// BAD - Using exceptions for normal flow
public User findUser(String username) throws UserNotFoundException {
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UserNotFoundException(username); // This is normal, not exceptional
}
return user;
}
// GOOD - Return optional instead
public Optional<User> findUser(String username) {
return Optional.ofNullable(userRepository.findByUsername(username));
}
// Usage
findUser("john").ifPresentOrElse(
user -> processUser(user),
() -> handleUserNotFound("john")
);
4. Ignoring the Cause
// BAD - Losing root cause
try {
connectToDatabase();
} catch (SQLException e) {
throw new DatabaseConnectionException("Connection failed"); // Root cause lost!
}
// GOOD - Preserve root cause
try {
connectToDatabase();
} catch (SQLException e) {
throw new DatabaseConnectionException("Connection failed", e); // Cause included
}
5. Not Making Exceptions Immutable
// BAD - Mutable exception state
public class BadException extends Exception {
public List<String> errors = new ArrayList<>(); // Public mutable field!
}
// GOOD - Immutable exception
public class GoodException extends Exception {
private final List<String> errors;
public GoodException(String message, List<String> errors) {
super(message);
this.errors = Collections.unmodifiableList(new ArrayList<>(errors));
}
public List<String> getErrors() {
return errors; // Returns immutable list
}
}
Summary
Custom exceptions in Java are powerful tools for creating meaningful, domain-specific error handling. Key takeaways:
- Use checked exceptions for recoverable conditions that callers should handle
- Use unchecked exceptions for programming errors or unrecoverable conditions
- Follow naming conventions and provide standard constructors
- Include contextual information to aid in debugging and recovery
- Preserve exception chains by including root causes
- Document your exceptions with JavaDoc
- Avoid common anti-patterns like empty catch blocks and using exceptions for control flow
Well-designed custom exceptions make your code more maintainable, debuggable, and user-friendly by providing clear, actionable error information.