7.3 Custom Exceptions and Exception Design

Designing effective custom exceptions is crucial for creating clear, maintainable APIs. Well-designed exceptions communicate intent, provide context, and enable proper error handling.

When to Create Custom Exceptions

Create Custom Exceptions When:

  1. You need domain-specific error information
  2. Different error scenarios require different handling
  3. You want to hide implementation details
  4. You need to add contextual data to exceptions

Use Built-in Exceptions When:

  1. The error is a common programming mistake
  2. The built-in exception clearly describes the error
  3. No additional context is needed

Basic Custom Exception

Simple Custom Exception:

// Unchecked exception (recommended for most cases)
public class OrderException extends RuntimeException {
    public OrderException(String message) {
        super(message);
    }

    public OrderException(String message, Throwable cause) {
        super(message, cause);
    }
}

// Usage
public void processOrder(Order order) {
    if (order.items().isEmpty()) {
        throw new OrderException("Order must contain at least one item");
    }
    // process order
}

Checked Custom Exception:

// Use sparingly - only when caller MUST handle
public class ConfigurationException extends Exception {
    public ConfigurationException(String message) {
        super(message);
    }

    public ConfigurationException(String message, Throwable cause) {
        super(message, cause);
    }
}

// Forces caller to handle
public Config loadConfig(Path file) throws ConfigurationException {
    try {
        return parseConfig(Files.readString(file));
    } catch (IOException e) {
        throw new ConfigurationException("Cannot load config from " + file, e);
    }
}

Exception Hierarchy

Domain Exception Hierarchy:

// Base exception for entire domain
public class DomainException extends RuntimeException {
    private final String errorCode;

    public DomainException(String message) {
        this(message, null, null);
    }

    public DomainException(String message, String errorCode) {
        this(message, errorCode, null);
    }

    public DomainException(String message, Throwable cause) {
        this(message, null, cause);
    }

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

    public String getErrorCode() {
        return errorCode;
    }
}

// Specific exception types
public class ValidationException extends DomainException {
    private final Map<String, String> fieldErrors;

    public ValidationException(String message) {
        this(message, Map.of());
    }

    public ValidationException(String message, Map<String, String> fieldErrors) {
        super(message, "VALIDATION_ERROR");
        this.fieldErrors = fieldErrors;
    }

    public Map<String, String> getFieldErrors() {
        return fieldErrors;
    }
}

public class EntityNotFoundException extends DomainException {
    private final String entityType;
    private final String entityId;

    public EntityNotFoundException(String entityType, String entityId) {
        super(
            String.format("%s not found with id: %s", entityType, entityId),
            "ENTITY_NOT_FOUND"
        );
        this.entityType = entityType;
        this.entityId = entityId;
    }

    public String getEntityType() { return entityType; }
    public String getEntityId() { return entityId; }
}

public class BusinessRuleException extends DomainException {
    private final String ruleId;

    public BusinessRuleException(String message, String ruleId) {
        super(message, "BUSINESS_RULE_VIOLATION");
        this.ruleId = ruleId;
    }

    public String getRuleId() { return ruleId; }
}

public class InsufficientPermissionException extends DomainException {
    private final String userId;
    private final String requiredPermission;

    public InsufficientPermissionException(String userId, String requiredPermission) {
        super(
            String.format("User %s lacks permission: %s", userId, requiredPermission),
            "INSUFFICIENT_PERMISSION"
        );
        this.userId = userId;
        this.requiredPermission = requiredPermission;
    }

    public String getUserId() { return userId; }
    public String getRequiredPermission() { return requiredPermission; }
}

Exception with Rich Context

Adding Contextual Information:

public class PaymentException extends DomainException {
    private final String orderId;
    private final BigDecimal amount;
    private final String paymentMethod;
    private final String gatewayResponse;
    private final Instant timestamp;

    private PaymentException(Builder builder) {
        super(builder.message, builder.errorCode, builder.cause);
        this.orderId = builder.orderId;
        this.amount = builder.amount;
        this.paymentMethod = builder.paymentMethod;
        this.gatewayResponse = builder.gatewayResponse;
        this.timestamp = builder.timestamp != null ? builder.timestamp : Instant.now();
    }

    // Getters
    public String getOrderId() { return orderId; }
    public BigDecimal getAmount() { return amount; }
    public String getPaymentMethod() { return paymentMethod; }
    public String getGatewayResponse() { return gatewayResponse; }
    public Instant getTimestamp() { return timestamp; }

    // Builder for complex construction
    public static class Builder {
        private final String message;
        private String errorCode;
        private Throwable cause;
        private String orderId;
        private BigDecimal amount;
        private String paymentMethod;
        private String gatewayResponse;
        private Instant timestamp;

        public Builder(String message) {
            this.message = message;
        }

        public Builder errorCode(String errorCode) {
            this.errorCode = errorCode;
            return this;
        }

        public Builder cause(Throwable cause) {
            this.cause = cause;
            return this;
        }

        public Builder orderId(String orderId) {
            this.orderId = orderId;
            return this;
        }

        public Builder amount(BigDecimal amount) {
            this.amount = amount;
            return this;
        }

        public Builder paymentMethod(String paymentMethod) {
            this.paymentMethod = paymentMethod;
            return this;
        }

        public Builder gatewayResponse(String gatewayResponse) {
            this.gatewayResponse = gatewayResponse;
            return this;
        }

        public Builder timestamp(Instant timestamp) {
            this.timestamp = timestamp;
            return this;
        }

        public PaymentException build() {
            return new PaymentException(this);
        }
    }

    @Override
    public String toString() {
        return String.format(
            "PaymentException{errorCode='%s', orderId='%s', amount=%s, " +
            "paymentMethod='%s', timestamp=%s, message='%s'}",
            getErrorCode(), orderId, amount, paymentMethod, timestamp, getMessage()
        );
    }
}

// Usage
throw new PaymentException.Builder("Payment processing failed")
    .errorCode("PAYMENT_DECLINED")
    .orderId("ORD-12345")
    .amount(new BigDecimal("99.99"))
    .paymentMethod("CREDIT_CARD")
    .gatewayResponse("Insufficient funds")
    .build();

Localized Exception Messages

Support for Multiple Languages:

public class LocalizedException extends RuntimeException {
    private final String messageKey;
    private final Object[] messageArgs;

    public LocalizedException(String messageKey, Object... messageArgs) {
        super(messageKey); // Default message key
        this.messageKey = messageKey;
        this.messageArgs = messageArgs;
    }

    public String getMessageKey() {
        return messageKey;
    }

    public Object[] getMessageArgs() {
        return messageArgs;
    }

    public String getLocalizedMessage(Locale locale) {
        ResourceBundle bundle = ResourceBundle.getBundle("messages", locale);
        String pattern = bundle.getString(messageKey);
        return MessageFormat.format(pattern, messageArgs);
    }
}

// messages.properties (English)
// user.not.found=User not found: {0}
// order.invalid.amount=Invalid order amount: {0}. Must be greater than {1}

// messages_es.properties (Spanish)
// user.not.found=Usuario no encontrado: {0}
// order.invalid.amount=Monto de pedido inválido: {0}. Debe ser mayor que {1}

// Usage
public class UserService {
    public User getUser(String userId, Locale locale) {
        User user = repository.findById(userId).orElse(null);
        if (user == null) {
            LocalizedException ex = new LocalizedException("user.not.found", userId);
            // Can log in English but return localized message to user
            logger.error("User not found: {}", userId);
            throw ex;
        }
        return user;
    }
}

// Handler
catch (LocalizedException e) {
    String localizedMessage = e.getLocalizedMessage(userLocale);
    return Response.error(localizedMessage);
}

Retry-able Exceptions

Marking Exceptions for Retry:

// Marker interface for transient failures
public interface RetryableException {
    default int getMaxRetries() {
        return 3;
    }

    default Duration getRetryDelay() {
        return Duration.ofSeconds(1);
    }
}

public class TransientDatabaseException extends DomainException implements RetryableException {
    public TransientDatabaseException(String message, Throwable cause) {
        super(message, "DATABASE_TRANSIENT_ERROR", cause);
    }

    @Override
    public int getMaxRetries() {
        return 5;
    }

    @Override
    public Duration getRetryDelay() {
        return Duration.ofMillis(500);
    }
}

public class NetworkTimeoutException extends DomainException implements RetryableException {
    public NetworkTimeoutException(String message) {
        super(message, "NETWORK_TIMEOUT");
    }

    @Override
    public int getMaxRetries() {
        return 3;
    }
}

// Retry handler
public class RetryHandler {
    public <T> T executeWithRetry(Supplier<T> operation) {
        int attempts = 0;
        Exception lastException = null;

        while (true) {
            try {
                return operation.get();
            } catch (Exception e) {
                lastException = e;
                attempts++;

                if (e instanceof RetryableException retryable) {
                    if (attempts >= retryable.getMaxRetries()) {
                        throw new RuntimeException(
                            "Max retries exceeded: " + attempts,
                            lastException
                        );
                    }

                    try {
                        Thread.sleep(retryable.getRetryDelay().toMillis());
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new RuntimeException("Retry interrupted", ie);
                    }
                } else {
                    // Not retryable, throw immediately
                    throw e;
                }
            }
        }
    }
}

// Usage
RetryHandler retryHandler = new RetryHandler();
User user = retryHandler.executeWithRetry(() -> repository.findById(userId));

Exception Wrapping Strategies

Transparent Wrapping:

// Infrastructure exceptions wrapped in domain exceptions
public class RepositoryException extends DomainException {
    public RepositoryException(String message, Throwable cause) {
        super(message, "REPOSITORY_ERROR", cause);
    }

    // Factory methods for common wrapping scenarios
    public static RepositoryException wrapSqlException(SQLException e, String operation) {
        return new RepositoryException(
            "Database error during " + operation + ": " + e.getMessage(),
            e
        );
    }

    public static RepositoryException wrapIoException(IOException e, String operation) {
        return new RepositoryException(
            "I/O error during " + operation + ": " + e.getMessage(),
            e
        );
    }
}

// Repository implementation
public class JdbcUserRepository {
    public User findById(String id) {
        try {
            PreparedStatement ps = connection.prepareStatement(
                "SELECT * FROM users WHERE id = ?"
            );
            ps.setString(1, id);
            ResultSet rs = ps.executeQuery();

            if (rs.next()) {
                return mapUser(rs);
            }
            return null;

        } catch (SQLException e) {
            throw RepositoryException.wrapSqlException(e, "findById");
        }
    }
}

Selective Wrapping:

public class ServiceException extends DomainException {
    public ServiceException(String message) {
        super(message);
    }

    public ServiceException(String message, Throwable cause) {
        super(message, cause);
    }

    // Wrap only checked exceptions
    public static RuntimeException wrap(Exception e) {
        if (e instanceof RuntimeException re) {
            return re; // Already unchecked, don't wrap
        }
        return new ServiceException("Service operation failed", e);
    }
}

// Usage
public User createUser(CreateUserRequest request) {
    try {
        validateRequest(request);
        return repository.save(mapToUser(request));
    } catch (Exception e) {
        throw ServiceException.wrap(e);
    }
}

Real-World Example: E-Commerce Order System

import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;

// Base exceptions
class OrderException extends RuntimeException {
    private final String errorCode;
    private final Map<String, Object> metadata = new HashMap<>();

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

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

    public String getErrorCode() {
        return errorCode;
    }

    public Map<String, Object> getMetadata() {
        return metadata;
    }

    public OrderException addMetadata(String key, Object value) {
        metadata.put(key, value);
        return this;
    }
}

// Specific exceptions
class OrderValidationException extends OrderException {
    private final Map<String, String> fieldErrors;

    public OrderValidationException(String message, Map<String, String> fieldErrors) {
        super(message, "ORDER_VALIDATION_ERROR");
        this.fieldErrors = fieldErrors;
    }

    public Map<String, String> getFieldErrors() {
        return fieldErrors;
    }
}

class InsufficientInventoryException extends OrderException {
    private final String productId;
    private final int requested;
    private final int available;

    public InsufficientInventoryException(String productId, int requested, int available) {
        super(
            String.format("Insufficient inventory for product %s: requested %d, available %d",
                productId, requested, available),
            "INSUFFICIENT_INVENTORY"
        );
        this.productId = productId;
        this.requested = requested;
        this.available = available;

        addMetadata("productId", productId);
        addMetadata("requested", requested);
        addMetadata("available", available);
    }

    public String getProductId() { return productId; }
    public int getRequested() { return requested; }
    public int getAvailable() { return available; }
}

class OrderLimitExceededException extends OrderException {
    private final BigDecimal orderAmount;
    private final BigDecimal userLimit;

    public OrderLimitExceededException(BigDecimal orderAmount, BigDecimal userLimit) {
        super(
            String.format("Order amount %s exceeds user limit %s", orderAmount, userLimit),
            "ORDER_LIMIT_EXCEEDED"
        );
        this.orderAmount = orderAmount;
        this.userLimit = userLimit;

        addMetadata("orderAmount", orderAmount);
        addMetadata("userLimit", userLimit);
    }

    public BigDecimal getOrderAmount() { return orderAmount; }
    public BigDecimal getUserLimit() { return userLimit; }
}

class PaymentDeclinedException extends OrderException {
    private final String paymentId;
    private final String declineReason;

    public PaymentDeclinedException(String paymentId, String declineReason) {
        super("Payment declined: " + declineReason, "PAYMENT_DECLINED");
        this.paymentId = paymentId;
        this.declineReason = declineReason;

        addMetadata("paymentId", paymentId);
        addMetadata("declineReason", declineReason);
    }

    public String getPaymentId() { return paymentId; }
    public String getDeclineReason() { return declineReason; }
}

// Service using custom exceptions
class OrderService {
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final OrderRepository orderRepository;

    public OrderService(InventoryService inventoryService, 
                       PaymentService paymentService,
                       OrderRepository orderRepository) {
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.orderRepository = orderRepository;
    }

    public Order createOrder(CreateOrderRequest request) {
        // Validate request
        Map<String, String> errors = validateRequest(request);
        if (!errors.isEmpty()) {
            throw new OrderValidationException("Order validation failed", errors);
        }

        // Calculate total
        BigDecimal total = calculateTotal(request);

        // Check user limits
        if (total.compareTo(request.user().orderLimit()) > 0) {
            throw new OrderLimitExceededException(total, request.user().orderLimit());
        }

        // Check inventory for all items
        for (OrderItem item : request.items()) {
            int available = inventoryService.getAvailable(item.productId());
            if (available < item.quantity()) {
                throw new InsufficientInventoryException(
                    item.productId(),
                    item.quantity(),
                    available
                );
            }
        }

        // Reserve inventory
        List<String> reservations = new ArrayList<>();
        try {
            for (OrderItem item : request.items()) {
                String reservationId = inventoryService.reserve(
                    item.productId(),
                    item.quantity()
                );
                reservations.add(reservationId);
            }

            // Process payment
            String paymentId;
            try {
                paymentId = paymentService.charge(
                    request.user().id(),
                    total,
                    request.paymentMethod()
                );
            } catch (PaymentGatewayException e) {
                // Release reservations
                releaseReservations(reservations);

                throw new PaymentDeclinedException(
                    e.getTransactionId(),
                    e.getReason()
                );
            }

            // Create order
            Order order = new Order(
                UUID.randomUUID().toString(),
                request.user().id(),
                request.items(),
                total,
                paymentId,
                Instant.now()
            );

            return orderRepository.save(order);

        } catch (Exception e) {
            // Release reservations on any error
            releaseReservations(reservations);
            throw e;
        }
    }

    private Map<String, String> validateRequest(CreateOrderRequest request) {
        Map<String, String> errors = new HashMap<>();

        if (request.user() == null) {
            errors.put("user", "User is required");
        }

        if (request.items() == null || request.items().isEmpty()) {
            errors.put("items", "Order must contain at least one item");
        } else {
            for (int i = 0; i < request.items().size(); i++) {
                OrderItem item = request.items().get(i);
                if (item.quantity() <= 0) {
                    errors.put("items[" + i + "].quantity", 
                             "Quantity must be positive");
                }
            }
        }

        if (request.paymentMethod() == null) {
            errors.put("paymentMethod", "Payment method is required");
        }

        return errors;
    }

    private BigDecimal calculateTotal(CreateOrderRequest request) {
        return request.items().stream()
            .map(item -> item.price().multiply(BigDecimal.valueOf(item.quantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    private void releaseReservations(List<String> reservations) {
        for (String reservationId : reservations) {
            try {
                inventoryService.release(reservationId);
            } catch (Exception e) {
                // Log but don't throw - best effort cleanup
                System.err.println("Failed to release reservation: " + reservationId);
            }
        }
    }
}

// Exception handler
class OrderExceptionHandler {
    public Response handleOrderException(OrderException e) {
        return switch (e.getErrorCode()) {
            case "ORDER_VALIDATION_ERROR" -> {
                OrderValidationException ve = (OrderValidationException) e;
                yield Response.badRequest(ve.getMessage(), ve.getFieldErrors());
            }
            case "INSUFFICIENT_INVENTORY" -> {
                InsufficientInventoryException ie = (InsufficientInventoryException) e;
                yield Response.conflict(ie.getMessage())
                    .withMetadata(ie.getMetadata());
            }
            case "ORDER_LIMIT_EXCEEDED" -> {
                OrderLimitExceededException le = (OrderLimitExceededException) e;
                yield Response.forbidden(le.getMessage())
                    .withMetadata(le.getMetadata());
            }
            case "PAYMENT_DECLINED" -> {
                PaymentDeclinedException pe = (PaymentDeclinedException) e;
                yield Response.paymentRequired(pe.getMessage())
                    .withMetadata(pe.getMetadata());
            }
            default -> Response.serverError("An error occurred");
        };
    }
}

Best Practices

1. Choose Unchecked Exceptions by Default

// Prefer unchecked
public class OrderException extends RuntimeException { }

// Only use checked when caller MUST handle
public class ConfigurationException extends Exception { }

2. Provide Multiple Constructors

public class DomainException extends RuntimeException {
    public DomainException(String message) {
        super(message);
    }

    public DomainException(String message, Throwable cause) {
        super(message, cause);
    }

    // Optional: for consistency with standard exceptions
    public DomainException(Throwable cause) {
        super(cause);
    }
}

3. Include Relevant Context

// Bad - not enough context
throw new OrderException("Invalid order");

// Good - specific context
throw new OrderException(
    "Order " + orderId + " invalid: total " + total + 
    " exceeds user limit " + userLimit
);

4. Don't Lose the Original Exception

// Bad - loses cause
catch (SQLException e) {
    throw new RepositoryException("Database error");
}

// Good - preserves cause
catch (SQLException e) {
    throw new RepositoryException("Database error", e);
}

5. Use Specific Exception Types

// Instead of generic exception
if (amount.compareTo(BigDecimal.ZERO) < 0) {
    throw new IllegalArgumentException("Amount negative");
}

// Use domain-specific
if (amount.compareTo(BigDecimal.ZERO) < 0) {
    throw new InvalidAmountException("Amount cannot be negative: " + amount);
}