Loading learning content...
A large application might have dozens or even hundreds of exception types. Without thoughtful organization, this becomes an unmanageable forest where developers can't find the right exception to throw or the right catch block to write.
Exception categorization transforms this chaos into navigable structure. By grouping related exceptions under meaningful hierarchies, we enable:
By completing this page, you will master: (1) the fundamental categories that apply across most applications, (2) how to design domain-specific category hierarchies, (3) organizing exceptions by layer, aggregate, and concern, (4) choosing the right depth for your hierarchy, and (5) category-based exception handling patterns.
Most applications benefit from a common set of top-level exception categories. These reflect universal concerns that appear regardless of domain—business logic failures, technical issues, security violations, and validation errors.
This categorization provides the first level of branching from your application's base exception class.
| Category | Purpose | HTTP Family | Typical Handling |
|---|---|---|---|
| BusinessException | Domain rules and invariants violated | 4xx (mostly 400, 409, 422) | Report to user, no retry, logic change needed |
| TechnicalException | Infrastructure and integration failures | 5xx (mostly 503) | Log, alert, automatic retry possible |
| SecurityException | Authentication and authorization failures | 401, 403, 429 | Redirect to login, show access denied, throttle |
| ValidationException | Input data or state doesn't meet requirements | 400 | Show field-level errors, fix input |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
/** * Business exceptions represent domain rule violations. * These are conditions that the business logic explicitly rejects. * * Characteristics: * - Deterministic: Same input always produces same exception * - Not retriable: Retry won't help without changing the request * - User-reportable: Message can typically be shown to users */public abstract class BusinessException extends ApplicationException { protected BusinessException(String message, String errorCode) { super(message, null, errorCode, Collections.emptyMap()); } protected BusinessException(String message, String errorCode, Map<String, Object> context) { super(message, null, errorCode, context); } @Override public boolean isRetriable() { return false; // Business failures don't resolve on retry } @Override public int suggestedHttpStatus() { return 400; // Bad Request by default }} /** * Technical exceptions represent infrastructure and integration failures. * These are failures in the systems that support the application. * * Characteristics: * - Often transient: May succeed on retry * - Not user-actionable: User can't fix infrastructure issues * - Needs operational attention: Alert, investigate, fix */public abstract class TechnicalException extends ApplicationException { protected TechnicalException(String message, Throwable cause, String errorCode) { super(message, cause, errorCode, Collections.emptyMap()); } @Override public boolean isRetriable() { return true; // Many technical failures are transient } @Override public int suggestedHttpStatus() { return 503; // Service Unavailable by default } /** * Suggested delay before retry, in milliseconds. * Subclasses may override based on failure type. */ public long suggestedRetryDelayMs() { return 1000; // 1 second default }} /** * Security exceptions represent authentication and authorization failures. * These indicate the caller's identity or permissions are insufficient. * * Characteristics: * - May need special logging (security audit) * - User may need to re-authenticate or escalate * - Should not reveal too much information to potential attackers */public abstract class SecurityException extends ApplicationException { protected SecurityException(String message, String errorCode) { super(message, null, errorCode, Collections.emptyMap()); } @Override public boolean isRetriable() { return false; // Security failures don't resolve on retry } @Override public int suggestedHttpStatus() { return 403; // Forbidden by default } /** * Whether this exception should be logged as a security event. * Default true; subclasses may override for routine failures. */ public boolean isSecurityEvent() { return true; }} /** * Validation exceptions represent input or state that doesn't meet requirements. * Distinguished from business exceptions because they're about data format, * not business rules. * * Characteristics: * - Typically field-level with specific error messages * - Collected and reported together (all validation errors at once) * - User can fix by correcting input */public class ValidationException extends ApplicationException { private final List<FieldError> fieldErrors; public ValidationException(List<FieldError> fieldErrors) { super( buildMessage(fieldErrors), null, "VALIDATION.FAILED", buildContext(fieldErrors) ); this.fieldErrors = List.copyOf(fieldErrors); } public static ValidationException of(FieldError... errors) { return new ValidationException(Arrays.asList(errors)); } public List<FieldError> getFieldErrors() { return fieldErrors; } @Override public int suggestedHttpStatus() { return 400; // Bad Request } @Value public static class FieldError { String field; String message; Object rejectedValue; String constraint; // e.g., "NotNull", "Size", "Email" } private static String buildMessage(List<FieldError> errors) { if (errors.isEmpty()) return "Validation failed"; if (errors.size() == 1) return errors.get(0).getMessage(); return format("Validation failed: %d error(s)", errors.size()); } private static Map<String, Object> buildContext(List<FieldError> errors) { return Map.of( "errorCount", errors.size(), "fields", errors.stream().map(FieldError::getField).toList() ); }}These four categories align with different concerns: Business = domain logic, Technical = infrastructure, Security = access control, Validation = data quality. Each suggests different retry logic, different HTTP status codes, different alerting thresholds, and different user messaging. This alignment makes handling patterns consistent and predictable.
Beyond fundamental categories, your exception hierarchy should reflect your domain model. In Domain-Driven Design terms, exceptions often cluster around aggregate roots—the primary entities that enforce consistency boundaries.
This organization makes exceptions discoverable: when working with orders, look under OrderException; when working with users, look under UserException.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
/** * E-Commerce domain organized by aggregate root. * Each aggregate has its own exception subtree. */ // =====================================================// ORDER AGGREGATE EXCEPTIONS// ===================================================== public abstract class OrderException extends BusinessException { protected final OrderId orderId; protected OrderException(String message, String errorCode, OrderId orderId) { super(message, "ORDER." + errorCode, Map.of("orderId", orderId.value())); this.orderId = orderId; } public OrderId getOrderId() { return orderId; }} // Specific order exceptionspublic class OrderNotFoundException extends OrderException { }public class OrderAlreadySubmittedException extends OrderException { }public class OrderCancellationException extends OrderException { }public class InsufficientInventoryException extends OrderException { }public class ShippingAddressRequiredException extends OrderException { } // =====================================================// USER AGGREGATE EXCEPTIONS// ===================================================== public abstract class UserException extends BusinessException { protected final UserId userId; protected UserException(String message, String errorCode, UserId userId) { super(message, "USER." + errorCode, Map.of("userId", userId.value())); this.userId = userId; }} // Specific user exceptionspublic class UserNotFoundException extends UserException { }public class UserAlreadyExistsException extends UserException { }public class UserSuspendedException extends UserException { }public class EmailNotVerifiedException extends UserException { }public class ProfileIncompleteException extends UserException { } // =====================================================// PAYMENT AGGREGATE EXCEPTIONS // ===================================================== public abstract class PaymentException extends BusinessException { protected final PaymentId paymentId; protected final Money amount; protected PaymentException(String message, String errorCode, PaymentId paymentId, Money amount) { super(message, "PAYMENT." + errorCode, Map.of( "paymentId", paymentId.value(), "amount", amount.toString() )); this.paymentId = paymentId; this.amount = amount; }} // Specific payment exceptionspublic class PaymentDeclinedException extends PaymentException { }public class PaymentAlreadyCaptured extends PaymentException { }public class RefundExceedsPaymentException extends PaymentException { }public class PaymentMethodExpiredException extends PaymentException { } // =====================================================// PRODUCT CATALOG EXCEPTIONS// ===================================================== public abstract class ProductException extends BusinessException { protected final ProductId productId; protected ProductException(String message, String errorCode, ProductId productId) { super(message, "PRODUCT." + errorCode, Map.of("productId", productId.value())); this.productId = productId; }} // Specific product exceptionspublic class ProductNotFoundException extends ProductException { }public class ProductDiscontinuedException extends ProductException { }public class PriceNotAvailableException extends ProductException { }public class ProductNotPurchasableException extends ProductException { }Organizing exceptions around aggregates provides several advantages:
ORDER., payment errors with PAYMENT., etc.OrderException to handle any order-related failure uniformly.In layered architectures, exceptions also organize by layer. Each layer has specific kinds of failures, and exceptions should stay within their proper abstraction level.
This prevents abstraction leakage: the presentation layer should never see SQLException, and the domain layer should never see HttpTimeoutException.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
// =====================================================// INFRASTRUCTURE LAYER// ===================================================== /** * Repository layer wraps database exceptions. * Domain layer never sees SQLException. */@Repositorypublic class JpaUserRepository implements UserRepository { @Override public User findById(UserId id) { try { var entity = entityManager.find(UserEntity.class, id.value()); if (entity == null) { throw new UserNotFoundException(id); } return userMapper.toDomain(entity); } catch (PersistenceException e) { // Wrap infrastructure exception in domain-appropriate type throw new RepositoryException( "Failed to fetch user: " + id, e, "USER.REPOSITORY.FETCH_FAILED" ); } } @Override public void save(User user) { try { var entity = userMapper.toEntity(user); entityManager.persist(entity); } catch (DataIntegrityViolationException e) { // Translate specific DB constraint to domain exception if (isUniqueConstraintViolation(e, "users_email_key")) { throw new UserAlreadyExistsException(user.getEmail()); } throw new RepositoryException("Failed to save user", e, "USER.REPOSITORY.SAVE_FAILED"); } }} // =====================================================// DOMAIN LAYER// ===================================================== /** * Domain service throws domain exceptions only. * Catches repository exceptions but rethrows as domain exceptions. */@DomainServicepublic class UserRegistrationService { public User registerUser(RegistrationCommand command) { // Domain validation - throws domain exceptions var email = Email.of(command.getEmail()); // May throw InvalidEmailException var password = Password.of(command.getPassword()); // May throw WeakPasswordException // Check business rules - throws domain exceptions if (userRepository.findByEmail(email).isPresent()) { throw new UserAlreadyExistsException(email); } // Create and persist var user = User.create(email, password); userRepository.save(user); // May throw RepositoryException return user; }} // =====================================================// APPLICATION LAYER// ===================================================== /** * Application service orchestrates use cases. * Catches domain exceptions, may wrap in use-case-specific exceptions. */@ApplicationServicepublic class UserRegistrationUseCase { @Transactional public UserDTO execute(RegisterUserRequest request) { try { // Delegate to domain service var user = registrationService.registerUser(toCommand(request)); // Application-level side effects eventPublisher.publish(new UserRegisteredEvent(user.getId())); emailService.sendWelcome(user.getEmail()); return userMapper.toDTO(user); } catch (UserAlreadyExistsException e) { // Rethrow domain exception as-is (appropriate at this level) throw e; } catch (EmailServiceException e) { // Log but don't fail registration for email issues log.warn("Failed to send welcome email", e); // Registration still succeeded return userMapper.toDTO(user); } }} // =====================================================// PRESENTATION LAYER// ===================================================== /** * Controller translates exceptions to HTTP responses. * Never catches infrastructure exceptions directly. */@RestControllerpublic class UserController { @PostMapping("/users") public ResponseEntity<UserDTO> register( @Valid @RequestBody RegisterUserRequest request) { try { var user = registrationUseCase.execute(request); return ResponseEntity.created(userUri(user)).body(user); } catch (UserAlreadyExistsException e) { // Domain exception → 409 Conflict throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage(), e); } catch (ValidationException e) { // Validation exception → 400 Bad Request with field errors throw e; // Handled by @ControllerAdvice } // Note: RepositoryException should NOT reach here if layers are clean // If it does, the global handler catches and returns 500 }}If your controller is catching SQLException or your domain service is catching HttpClientException, your abstraction boundaries are leaking. Each layer should only throw and catch exceptions appropriate to its level of abstraction. Infrastructure wraps external exceptions into infrastructure exceptions; repositories wrap those into domain-appropriate exceptions.
A common design question: How deep should the exception hierarchy be? Too shallow, and you can't differentiate failures. Too deep, and navigation becomes cumbersome and catching requires excessive specificity.
Most applications benefit from 3-4 levels of exception hierarchy:
ApplicationExceptionBusinessException, TechnicalException, etc.OrderException, PaymentException, etc.InsufficientInventoryException, PaymentDeclinedException| Depth | Pros | Cons | When to Use |
|---|---|---|---|
| 1-2 levels | Simple, easy to navigate | Can't differentiate, poor catching granularity | Very small applications, scripts |
| 3-4 levels | Good balance, sufficient differentiation | Requires more upfront design | Most applications |
| 5+ levels | Maximum differentiation | Hard to navigate, over-engineered | Almost never—reconsider design |
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// ❌ TOO SHALLOW: Only 2 levelsApplicationException└── OrderNotFoundException└── PaymentDeclinedException└── UserSuspendedException└── DatabaseTimeoutException// Can't catch "all order errors" or "all business errors" // ❌ TOO DEEP: 6+ levelsApplicationException└── BusinessException └── OrderException └── OrderStateException └── OrderCancellationException └── OrderCancellationAfterShipmentException └── OrderCancellationAfterPartialShipmentException// Over-engineered; hard to navigate; probably unnecessary differentiation // ✅ JUST RIGHT: 3-4 levelsApplicationException├── BusinessException│ ├── OrderException│ │ ├── OrderNotFoundException│ │ ├── OrderAlreadySubmittedException│ │ └── InsufficientInventoryException│ ├── PaymentException│ │ ├── PaymentDeclinedException│ │ └── RefundExceedsPaymentException│ └── UserException│ ├── UserNotFoundException│ └── UserSuspendedException├── TechnicalException│ ├── DatabaseException│ └── ExternalServiceException└── SecurityException ├── AuthenticationException └── AuthorizationException // This hierarchy enables:// - Catch ApplicationException for "any application error"// - Catch BusinessException for "any business rule violation"// - Catch OrderException for "any order-related error"// - Catch InsufficientInventoryException for this specific caseEvery level in your hierarchy should have catch blocks that use it. If you have OrderStateException but nobody ever catches it (they catch OrderException or specific subtypes), that level is unnecessary. Design your hierarchy based on actual catching patterns, not theoretical categorization.
Some categorizations cut across domain boundaries. These represent common failure patterns that appear in multiple aggregates:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
/** * Interface for exceptions that can be safely retried. * Used by retry infrastructure to determine retry eligibility. */public interface Retriable { /** * Whether this exception represents a transient condition * that may succeed on retry. */ boolean isRetriable(); /** * Suggested delay before retry, in milliseconds. */ default long suggestedRetryDelayMs() { return 1000; } /** * Maximum number of retry attempts. */ default int maxRetries() { return 3; }} /** * Interface for exceptions where an entity wasn't found. * Enables generic not-found handling across domains. */public interface EntityNotFound { /** * Type of entity that wasn't found (e.g., "Order", "User"). */ String getEntityType(); /** * Identifier of the entity that wasn't found. */ String getEntityId();} /** * Marker interface for conflicts (concurrent modification, duplicate, etc.) */public interface Conflict { /** * Description of what conflicted. */ String getConflictDescription();} // Domain exceptions implement these interfaces as needed: public class UserNotFoundException extends UserException implements EntityNotFound { @Override public String getEntityType() { return "User"; } @Override public String getEntityId() { return userId.value(); }} public class DatabaseTimeoutException extends TechnicalException implements Retriable { @Override public boolean isRetriable() { return true; } @Override public long suggestedRetryDelayMs() { return 5000; // Longer delay for database operations }} public class UserAlreadyExistsException extends UserException implements Conflict { @Override public String getConflictDescription() { return "User with email " + email + " already exists"; } @Override public int suggestedHttpStatus() { return 409; // Conflict }} // Generic handlers can now work across domains: @ControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler public ResponseEntity<ErrorResponse> handleEntityNotFound( ApplicationException ex) { if (ex instanceof EntityNotFound notFound) { return ResponseEntity.notFound() .header("X-Entity-Type", notFound.getEntityType()) .build(); } // ... other handling } @ExceptionHandler public ResponseEntity<ErrorResponse> handleConflict( ApplicationException ex) { if (ex instanceof Conflict conflict) { return ResponseEntity.status(HttpStatus.CONFLICT) .body(new ErrorResponse(conflict.getConflictDescription())); } // ... other handling }}Use inheritance (extends) for 'is-a' relationships: UserNotFoundException IS A BusinessException. Use interfaces (implements) for cross-cutting capabilities: UserNotFoundException CAN BE checked for entity-not-found handling. This keeps your hierarchy clean while enabling flexible handling patterns.
Exception categories aren't just for code—they're essential for operational visibility. Your monitoring and alerting systems should leverage exception categorization to provide meaningful dashboards and alerts.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
/** * Exception metrics collector that categorizes exceptions * for monitoring dashboards and alerting. */@Componentpublic class ExceptionMetricsCollector { private final MeterRegistry meterRegistry; /** * Record exception occurrence with categorization tags. */ public void recordException(ApplicationException exception) { Tags tags = Tags.of( // Primary categorization "category", getCategoryTag(exception), "domain", getDomainTag(exception), "error_code", exception.getErrorCode() != null ? exception.getErrorCode() : "UNKNOWN", // Characteristics "retriable", String.valueOf(exception.isRetriable()), "http_status", String.valueOf(exception.suggestedHttpStatus()), // Severity (derived from category) "severity", getSeverity(exception) ); meterRegistry.counter("application.exceptions", tags).increment(); // Also record to histogram for latency-to-error analysis if (exception.getTimestamp() != null) { long latency = Duration.between( exception.getTimestamp(), Instant.now() ).toMillis(); meterRegistry.timer("application.exception_latency", tags) .record(latency, TimeUnit.MILLISECONDS); } } private String getCategoryTag(ApplicationException ex) { if (ex instanceof BusinessException) return "business"; if (ex instanceof TechnicalException) return "technical"; if (ex instanceof SecurityException) return "security"; if (ex instanceof ValidationException) return "validation"; return "other"; } private String getDomainTag(ApplicationException ex) { // Extract domain from error code (e.g., "ORDER.NOT_FOUND" → "order") String code = ex.getErrorCode(); if (code != null && code.contains(".")) { return code.substring(0, code.indexOf('.')).toLowerCase(); } // Fallback: use class name return ex.getClass().getSimpleName() .replace("Exception", "") .toLowerCase(); } private String getSeverity(ApplicationException ex) { if (ex instanceof TechnicalException) return "high"; if (ex instanceof SecurityException) { if (ex instanceof AuthenticationException) return "medium"; return "high"; // Authorization failures may indicate attacks } if (ex instanceof BusinessException) return "low"; return "medium"; }} /** * Alerting rules based on exception categorization. */// Example Prometheus alerting rules (as YAML):/*groups: - name: exception_alerts rules: # High rate of technical exceptions - infrastructure issue - alert: HighTechnicalExceptionRate expr: | rate(application_exceptions_total{category="technical"}[5m]) > 10 for: 2m labels: severity: page annotations: summary: "High technical exception rate" # Any security exceptions - potential attack - alert: SecurityExceptionsDetected expr: | increase(application_exceptions_total{category="security"}[5m]) > 0 for: 0m labels: severity: warning annotations: summary: "Security exceptions detected" # Sudden spike in business exceptions - may indicate bad deploy - alert: BusinessExceptionSpike expr: | rate(application_exceptions_total{category="business"}[5m]) > 2 * rate(application_exceptions_total{category="business"}[1h]) for: 5m labels: severity: warning annotations: summary: "Unusual increase in business exceptions"*/business, technical, security, validation enable category-level dashboardsorder, payment, user enable domain-level analysisException categorization transforms a flat list of error types into a navigable, meaningful hierarchy that serves code organization, error handling, and operational monitoring.
What's next:
With structure in place, we need naming conventions that make exceptions self-documenting. The final page covers how to name exception classes, error codes, and messages for maximum clarity and consistency.
You now understand how to organize exceptions into meaningful categories that enable appropriate handling, clear documentation, and effective monitoring. Your exception hierarchies will be navigable, purposeful, and operationally useful.