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:
- You need domain-specific error information
- Different error scenarios require different handling
- You want to hide implementation details
- You need to add contextual data to exceptions
Use Built-in Exceptions When:
- The error is a common programming mistake
- The built-in exception clearly describes the error
- 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);
}