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.