Loading learning content...
Base exception classes establish structure; custom exception classes bring meaning. While BusinessException tells you something went wrong with business logic, InsufficientInventoryException tells you exactly what happened, why, and possibly how to fix it.
The art of designing custom exceptions lies in finding the right balance: rich enough to be genuinely useful for debugging and recovery, but not so complex that creating or catching them becomes a burden. This page teaches you to design exceptions that work for both developers and operations teams.
By completing this page, you will master: (1) what information custom exceptions should carry, (2) patterns for exception constructors that balance convenience with consistency, (3) when to create new exception types vs. reuse existing ones, (4) how to make exceptions useful for logging, monitoring, and debugging, and (5) domain-specific exception design across multiple real-world scenarios.
Before designing custom exceptions, we must understand what they're actually for. Custom exceptions serve four distinct purposes, each influencing their design:
UserNotFoundException triggers a 404; UserSuspendedException triggers a 403. The exception type IS the API.The key insight: A well-designed exception answers the questions "What happened?", "Why?", "Where?", and "What can be done about it?" — all without requiring the handler to parse a message string.
1234567891011121314151617181920212223
// ❌ POOR: Generic exception with just a messagethrow new BusinessException("Order cannot be placed");// Handler knows NOTHING about what went wrong // ✅ RICH: Custom exception answering all key questionsthrow new OrderPlacementException( orderId, // WHAT: Which order OrderPlacementFailure.INSUFFICIENT_INVENTORY, // WHY: Reason category insufficientItems, // CONTEXT: Which items, how many needed/available canRetry: false // ACTIONABLE: Can we retry?); // Handler can now take intelligent action:catch (OrderPlacementException e) { if (e.getFailure() == INSUFFICIENT_INVENTORY) { notifyCustomer(e.getOrderId(), e.getInsufficientItems()); return suggestAlternatives(e.getInsufficientItems()); } if (e.getFailure() == PAYMENT_DECLINED) { return promptForAlternatePayment(e.getOrderId()); } // ... etc}Every custom exception must decide what data to include. Too little, and handlers can't make informed decisions. Too much, and exception creation becomes complex and exceptions become unwieldy. Here's a framework for making these decisions:
This data should be present in virtually every custom exception:
| Data | Purpose | Implementation |
|---|---|---|
| Message | Human-readable description for logs and debugging | Always required; construct from other fields for consistency |
| Error Code | Machine-readable identifier for monitoring and categorization | String like ORDER.INVENTORY.INSUFFICIENT; inherited from base class |
| Entity Identifier | Which specific entity the error relates to | Order ID, User ID, etc. — typed ID objects preferred |
| Timestamp | When the error occurred | Usually inherited from base class; use UTC |
Beyond the essentials, include data specific to the failure mode:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
/** * Exception for insufficient inventory during order placement. * Carries all information needed for handlers to respond intelligently. */public class InsufficientInventoryException extends OrderException { // Essential: Which order was affected private final OrderId orderId; // Domain-specific: Details about what's insufficient private final List<InsufficientItem> insufficientItems; // Actionable: When inventory might be available private final Instant estimatedRestockDate; // Recovery hint: Can partial fulfillment work? private final boolean partialFulfillmentPossible; @Value // Immutable nested type for structured data public static class InsufficientItem { ProductId productId; String productName; int requestedQuantity; int availableQuantity; int shortfall; // requestedQuantity - availableQuantity } public InsufficientInventoryException( OrderId orderId, List<InsufficientItem> insufficientItems, Instant estimatedRestockDate, boolean partialFulfillmentPossible) { super( buildMessage(orderId, insufficientItems), "ORDER.INVENTORY.INSUFFICIENT", buildContext(orderId, insufficientItems) ); this.orderId = orderId; this.insufficientItems = List.copyOf(insufficientItems); this.estimatedRestockDate = estimatedRestockDate; this.partialFulfillmentPossible = partialFulfillmentPossible; } private static String buildMessage(OrderId orderId, List<InsufficientItem> items) { return String.format( "Order %s cannot be fulfilled: insufficient inventory for %d item(s)", orderId.value(), items.size() ); } private static Map<String, Object> buildContext( OrderId orderId, List<InsufficientItem> items) { return Map.of( "orderId", orderId.value(), "insufficientItemCount", items.size(), "productIds", items.stream() .map(i -> i.getProductId().value()) .collect(toList()), "totalShortfall", items.stream() .mapToInt(InsufficientItem::getShortfall) .sum() ); } @Override public int suggestedHttpStatus() { return 422; // Unprocessable Entity } // Getters for all fields public OrderId getOrderId() { return orderId; } public List<InsufficientItem> getInsufficientItems() { return insufficientItems; } public Optional<Instant> getEstimatedRestockDate() { return Optional.ofNullable(estimatedRestockDate); } public boolean isPartialFulfillmentPossible() { return partialFulfillmentPossible; }}Exception data should be immutable. Once created, exceptions shouldn't change. Use immutable collections (List.copyOf, Object.freeze, tuple) and immutable data classes for nested structures. This prevents subtle bugs where exception data is modified between throw and catch.
How you design exception constructors significantly impacts usability. You want creation to be convenient at throw sites while ensuring all necessary information is captured. Here are proven patterns:
The simplest approach: require exactly what's needed, nothing more.
1234567891011121314151617181920
/** * Simple exception with required parameters only. * Use when there's a single, obvious set of data needed. */public class UserNotFoundException extends EntityNotFoundException { private final UserId userId; public UserNotFoundException(UserId userId) { super("User not found: " + userId.value(), "USER.NOT_FOUND"); this.userId = userId; } public UserId getUserId() { return userId; }} // Clean throw site:throw new UserNotFoundException(userId);For exceptions with many optional parameters, use a builder to prevent constructor explosion.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
/** * Exception with many optional parameters using builder pattern. */public class ValidationException extends BusinessException { private final String field; private final Object rejectedValue; private final String constraint; private final List<String> details; private ValidationException(Builder builder) { super(builder.buildMessage(), "VALIDATION.FAILED", builder.buildContext()); this.field = builder.field; this.rejectedValue = builder.rejectedValue; this.constraint = builder.constraint; this.details = List.copyOf(builder.details); } // Static factory method starts the builder public static Builder forField(String field) { return new Builder(field); } public static class Builder { private final String field; private Object rejectedValue; private String constraint; private List<String> details = new ArrayList<>(); private Builder(String field) { this.field = requireNonNull(field, "field"); } public Builder withValue(Object value) { this.rejectedValue = value; return this; } public Builder violating(String constraint) { this.constraint = constraint; return this; } public Builder addDetail(String detail) { this.details.add(detail); return this; } public ValidationException build() { return new ValidationException(this); } private String buildMessage() { return String.format("Validation failed for field '%s': %s", field, constraint != null ? constraint : "invalid value"); } private Map<String, Object> buildContext() { Map<String, Object> ctx = new HashMap<>(); ctx.put("field", field); if (rejectedValue != null) ctx.put("rejectedValue", sanitize(rejectedValue)); if (constraint != null) ctx.put("constraint", constraint); if (!details.isEmpty()) ctx.put("details", details); return ctx; } private Object sanitize(Object value) { // Don't expose sensitive data like passwords in logs if (field.toLowerCase().contains("password")) { return "[REDACTED]"; } return value; } }} // Fluent throw site:throw ValidationException.forField("email") .withValue(email) .violating("must be a valid email address") .addDetail("Must contain exactly one @ symbol") .addDetail("Domain must have at least one dot") .build();Provide named constructors for different creation scenarios, making throw sites self-documenting.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
/** * Exception with static factory methods for different scenarios. * Makes throw sites highly readable and self-documenting. */public class PaymentException extends BusinessException { private final PaymentId paymentId; private final PaymentFailureReason reason; private final Money amount; // Private constructor - use factories private PaymentException( String message, String errorCode, PaymentId paymentId, PaymentFailureReason reason, Money amount) { super(message, errorCode, Map.of( "paymentId", paymentId.value(), "reason", reason.name(), "amount", amount.toString() )); this.paymentId = paymentId; this.reason = reason; this.amount = amount; } // Factory: Card declined public static PaymentException cardDeclined(PaymentId paymentId, Money amount) { return new PaymentException( "Payment declined: card rejected by issuer", "PAYMENT.CARD_DECLINED", paymentId, PaymentFailureReason.CARD_DECLINED, amount ); } // Factory: Insufficient funds public static PaymentException insufficientFunds(PaymentId paymentId, Money amount) { return new PaymentException( "Payment failed: insufficient funds", "PAYMENT.INSUFFICIENT_FUNDS", paymentId, PaymentFailureReason.INSUFFICIENT_FUNDS, amount ); } // Factory: Expired card public static PaymentException expiredCard(PaymentId paymentId, Money amount) { return new PaymentException( "Payment failed: card has expired", "PAYMENT.CARD_EXPIRED", paymentId, PaymentFailureReason.CARD_EXPIRED, amount ); } // Factory: Gateway timeout (retriable) public static PaymentException gatewayTimeout(PaymentId paymentId, Money amount) { return new PaymentException( "Payment failed: gateway timeout", "PAYMENT.GATEWAY_TIMEOUT", paymentId, PaymentFailureReason.GATEWAY_TIMEOUT, amount ) { @Override public boolean isRetriable() { return true; // Override for this specific failure } }; } @Override public int suggestedHttpStatus() { return reason == PaymentFailureReason.GATEWAY_TIMEOUT ? 503 : 422; }} // Self-documenting throw sites:throw PaymentException.cardDeclined(paymentId, amount);throw PaymentException.insufficientFunds(paymentId, amount);throw PaymentException.gatewayTimeout(paymentId, amount);Use required parameters when the exception has 1-3 parameters that are always needed. Use builders when there are many optional parameters or the construction logic is complex. Use static factories when there are distinct creation scenarios that benefit from named methods.
One of the most common design questions: Should I create a new exception class, or reuse an existing one with a different message? The answer depends on whether callers need to distinguish this failure from others.
123456789101112131415161718192021222324252627282930313233343536373839
// ✅ GOOD: Different types when handling differspublic class UserNotFoundException extends EntityNotFoundException { // Caught to return 404, show "user not found" message} public class UserSuspendedException extends UserException { // Caught to return 403, show "account suspended" message // Contains suspension reason, end date, appeal information} public class UserEmailNotVerifiedException extends UserException { // Caught to redirect to verification flow // Contains email, verification token expiry} // ✅ GOOD: Same base type when handling is uniformpublic class ValidationException extends BusinessException { private final List<FieldError> errors; // Generic validation exception with list of specific field errors // All validation failures → 400 Bad Request // No need for separate EmailValidationException, PhoneValidationException, etc.} // ❌ BAD: Type proliferation with no handling differencepublic class UserFirstNameTooLongException extends ValidationException { }public class UserFirstNameTooShortException extends ValidationException { }public class UserFirstNameContainsNumbersException extends ValidationException { }// All of these → 400 Bad Request with field error// Just use ValidationException with specific field errors // ❌ BAD: Using message to differentiate when types would helpthrow new BusinessException("User not found: " + userId);throw new BusinessException("User suspended: " + userId);throw new BusinessException("User email not verified: " + userId);// Caller can't distinguish without parsing message strings!Ask: 'Will any caller ever want to catch this specific exception and handle it differently from its parent?' If yes, create a new type. If no, use the parent type with appropriate message/context. This simple test prevents both under-differentiation (can't tell failures apart) and over-differentiation (hundreds of exception classes nobody catches specifically).
Exception messages serve multiple audiences: developers reading logs, operators investigating incidents, and sometimes end users seeing error pages. Good message design considers all these consumers.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
public class ExceptionMessages { // ❌ BAD: Too vague throw new ServiceException("Operation failed"); throw new ValidationException("Invalid input"); throw new DatabaseException("Database error"); // ✅ GOOD: Specific and actionable throw new ServiceException( "Order processing failed for order 12345: payment gateway timeout after 30s" ); throw new ValidationException( "Email validation failed for field 'contact.email': " + "'not-an-email' is not a valid email address" ); throw new DatabaseException( "Failed to insert user record: duplicate key violation on 'users.email' " + "with value 'user@example.com'" ); // ❌ BAD: Exposes sensitive data throw new AuthenticationException( "Login failed for user 'admin' with password 'secret123'" // NEVER DO THIS ); // ✅ GOOD: Redacts sensitive data throw new AuthenticationException( "Authentication failed for user 'admin': invalid credentials" ); // ❌ BAD: Prescriptive (tells user what to do) throw new RateLimitException("Please wait 60 seconds before trying again"); // ✅ GOOD: Descriptive (states what happened) throw new RateLimitException( "Rate limit exceeded for API key ending in ...3f2x: " + "100 requests in 60 seconds (limit: 50)" ); // ✅ MESSAGE TEMPLATE PATTERN // Centralize message construction for consistency public static class OrderMessages { public static String orderNotFound(OrderId orderId) { return format("Order %s not found in active orders", orderId.value()); } public static String insufficientInventory(OrderId orderId, int itemCount) { return format( "Order %s cannot be fulfilled: insufficient inventory for %d item(s)", orderId.value(), itemCount ); } public static String paymentFailed(OrderId orderId, String reason) { return format( "Payment for order %s failed: %s", orderId.value(), reason ); } } // Usage: throw new OrderNotFoundException( OrderMessages.orderNotFound(orderId), orderId );}When exceptions reach API boundaries, they must be serialized into response formats (typically JSON). Well-designed exceptions make this transformation straightforward while maintaining security and useful error information.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
/** * Standard error response DTO for REST APIs. */@Value@Builderpublic class ErrorResponse { String errorCode; String message; String path; Instant timestamp; String traceId; List<FieldError> fieldErrors; Map<String, Object> details; @Value public static class FieldError { String field; String message; Object rejectedValue; }} /** * Global exception handler converts exceptions to API responses. */@RestControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler(ValidationException.class) public ResponseEntity<ErrorResponse> handleValidation( ValidationException ex, HttpServletRequest request) { ErrorResponse error = ErrorResponse.builder() .errorCode(ex.getErrorCode()) .message("Validation failed") .path(request.getRequestURI()) .timestamp(ex.getTimestamp()) .traceId(MDC.get("traceId")) .fieldErrors(ex.getFieldErrors().stream() .map(fe -> new ErrorResponse.FieldError( fe.getField(), fe.getMessage(), sanitize(fe.getRejectedValue()) )) .toList()) .build(); return ResponseEntity .status(ex.suggestedHttpStatus()) .body(error); } @ExceptionHandler(EntityNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound( EntityNotFoundException ex, HttpServletRequest request) { ErrorResponse error = ErrorResponse.builder() .errorCode(ex.getErrorCode()) .message(ex.getMessage()) // Safe to expose for not-found .path(request.getRequestURI()) .timestamp(ex.getTimestamp()) .traceId(MDC.get("traceId")) .build(); return ResponseEntity.notFound().build(); } @ExceptionHandler(BusinessException.class) public ResponseEntity<ErrorResponse> handleBusiness( BusinessException ex, HttpServletRequest request) { ErrorResponse error = ErrorResponse.builder() .errorCode(ex.getErrorCode()) .message(ex.getMessage()) .path(request.getRequestURI()) .timestamp(ex.getTimestamp()) .traceId(MDC.get("traceId")) .details(filterSensitiveContext(ex.getContext())) .build(); return ResponseEntity .status(ex.suggestedHttpStatus()) .body(error); } @ExceptionHandler(TechnicalException.class) public ResponseEntity<ErrorResponse> handleTechnical( TechnicalException ex, HttpServletRequest request) { // Log full details server-side log.error("Technical exception", ex); // Return minimal info to client (don't expose internals) ErrorResponse error = ErrorResponse.builder() .errorCode("INTERNAL_ERROR") // Generic code .message("An internal error occurred") // Generic message .path(request.getRequestURI()) .timestamp(Instant.now()) .traceId(MDC.get("traceId")) // Include trace ID for support .build(); return ResponseEntity .status(503) .body(error); } private Object sanitize(Object value) { // Never expose passwords, tokens, etc. if (value == null) return null; String str = value.toString(); if (str.length() > 100) { return str.substring(0, 100) + "..."; } return value; } private Map<String, Object> filterSensitiveContext(Map<String, Object> context) { return context.entrySet().stream() .filter(e -> !isSensitiveKey(e.getKey())) .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); } private boolean isSensitiveKey(String key) { String lower = key.toLowerCase(); return lower.contains("password") || lower.contains("token") || lower.contains("secret") || lower.contains("credential"); }}Never expose internal exception details to external clients. Technical exceptions, stack traces, and internal error codes help attackers understand your system. Always transform TechnicalException into generic responses while logging full details server-side. Include a trace ID so support can correlate client reports with server logs.
Let's see how these principles apply across different domains. Each example shows a cohesive set of related exceptions with appropriate granularity and information.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
/** * E-Commerce domain exception hierarchy. * Organized by aggregate root (Order, Cart, Product, etc.) */ // =============================================// ORDER EXCEPTIONS// ============================================= public abstract class OrderException extends BusinessException { protected final OrderId orderId; protected OrderException(String message, String errorCode, OrderId orderId) { super(message, errorCode, Map.of("orderId", orderId.value())); this.orderId = orderId; }} public class OrderNotFoundException extends OrderException { public OrderNotFoundException(OrderId orderId) { super("Order not found: " + orderId.value(), "ORDER.NOT_FOUND", orderId); } @Override public int suggestedHttpStatus() { return 404; }} public class OrderAlreadyCancelledException extends OrderException { private final Instant cancelledAt; private final String cancellationReason; public OrderAlreadyCancelledException( OrderId orderId, Instant cancelledAt, String reason) { super("Order was already cancelled", "ORDER.ALREADY_CANCELLED", orderId); this.cancelledAt = cancelledAt; this.cancellationReason = reason; } @Override public int suggestedHttpStatus() { return 409; }} public class OrderNotCancellableException extends OrderException { private final OrderStatus currentStatus; private final Set<OrderStatus> cancellableStatuses; public OrderNotCancellableException( OrderId orderId, OrderStatus currentStatus, Set<OrderStatus> cancellableStatuses) { super(format("Order cannot be cancelled in status %s", currentStatus), "ORDER.NOT_CANCELLABLE", orderId); this.currentStatus = currentStatus; this.cancellableStatuses = cancellableStatuses; } @Override public int suggestedHttpStatus() { return 422; }} // =============================================// PAYMENT EXCEPTIONS // ============================================= public abstract class PaymentException extends BusinessException { protected final PaymentId paymentId; protected final Money amount;} public class PaymentDeclinedException extends PaymentException { private final DeclineReason declineReason; public enum DeclineReason { INSUFFICIENT_FUNDS, CARD_EXPIRED, CARD_BLOCKED, FRAUD_SUSPECTED, INVALID_CVV, UNKNOWN } public PaymentDeclinedException( PaymentId paymentId, Money amount, DeclineReason reason) { super("Payment declined: " + reason, "PAYMENT.DECLINED." + reason.name(), paymentId, amount); this.declineReason = reason; } public boolean shouldRetryWithDifferentCard() { return declineReason == DeclineReason.CARD_EXPIRED || declineReason == DeclineReason.CARD_BLOCKED || declineReason == DeclineReason.INVALID_CVV; }} public class PaymentCaptureException extends PaymentException { private final String gatewayErrorCode; public PaymentCaptureException( PaymentId paymentId, Money amount, String gatewayErrorCode, Throwable cause) { super("Payment capture failed: " + gatewayErrorCode, "PAYMENT.CAPTURE_FAILED", paymentId, amount, cause); this.gatewayErrorCode = gatewayErrorCode; } @Override public boolean isRetriable() { return gatewayErrorCode.startsWith("TIMEOUT"); }}Custom exception classes are the semantic layer of your error handling architecture. They transform generic 'something went wrong' signals into rich, actionable information that enables intelligent handling, effective debugging, and operational visibility.
What's next:
With custom exceptions designed, we need to organize them. The next page covers Exception Categorization—how to group exceptions into meaningful hierarchies that reflect your domain, enable appropriate handling at each layer, and support operational monitoring.
You now understand how to design custom exception classes that carry rich, actionable information while remaining practical to use. Your exceptions will enable callers to handle errors intelligently rather than just logging generic failure messages.