Loading content...
Every robust software system must communicate failure. When a database connection drops, when input validation fails, when business rules are violated—the system must signal these conditions in ways that callers can understand, handle, and recover from. At the heart of this communication lies a surprisingly nuanced question: How should we structure the types that represent failures?
The answer to this question shapes everything downstream: how callers catch and handle errors, how errors propagate across module boundaries, how teams reason about failure modes, and ultimately, how reliable and maintainable your system becomes.
This page examines the base exception classes that form the foundation of exception hierarchies—the root types from which all other exceptions derive. Understanding these foundations is essential before you can design effective custom exception hierarchies for your own systems.
By completing this page, you will understand: (1) how major languages structure their base exception classes and why, (2) the semantic differences between exception categories at the root level, (3) how to leverage base class hierarchies for effective error handling, and (4) the design principles that guide robust exception hierarchy architecture.
In object-oriented programming, exceptions are represented as objects—instances of classes that carry information about what went wrong. These classes don't exist in isolation; they're organized into hierarchies where more specific exception types inherit from more general ones.
This hierarchical structure serves a fundamental purpose: it enables catch blocks to operate at different levels of specificity. You can catch a very specific exception type when you know exactly how to handle it, or catch a broader base type when you want to handle an entire category of failures uniformly.
12345678910111213141516171819202122232425262728
// Exception hierarchy enables different handling granularities try { processUserRequest(request);} catch (UserNotFoundException e) { // Very specific: handle this particular failure mode return notFound("User not found: " + e.getUserId()); } catch (ValidationException e) { // Medium specificity: any validation failure return badRequest(e.getValidationErrors()); } catch (BusinessException e) { // Broader category: any business rule violation log.warn("Business rule violated", e); return conflict(e.getMessage()); } catch (ApplicationException e) { // Application-level catch-all (our custom base) log.error("Application error", e); return internalError("An error occurred"); } catch (RuntimeException e) { // Language runtime exceptions log.error("Unexpected runtime error", e); reportToMonitoring(e); return internalError("Unexpected error");}The hierarchical structure provides three critical benefits:
Precision when needed: Catch specific types like UserNotFoundException when you have a specific recovery strategy
Categorization for common handling: Catch intermediate types like ValidationException to handle all validation failures uniformly
Safety net for unanticipated failures: Catch base types like ApplicationException to ensure nothing escapes without logging
This layered approach is only possible because exception classes inherit from base classes in a well-designed hierarchy.
Before designing your own exception hierarchies, you must understand the base classes provided by your target language. Each language's exception system reflects different philosophies about error handling, and your custom exceptions must integrate properly with these foundations.
Java has one of the most carefully designed exception hierarchies, distinguishing between recoverable and unrecoverable conditions at the language level:
| Class | Purpose | When to Extend/Catch | Checked? |
|---|---|---|---|
| Throwable | Root of all throwable objects | Never extend directly; catch only in top-level handlers | N/A |
| Error | Serious problems that applications shouldn't try to handle | Never extend; never catch (usually) | No |
| Exception | Conditions that applications might want to catch | Extend for custom checked exceptions | Yes |
| RuntimeException | Exceptions that can be thrown during normal JVM operation | Extend for custom unchecked exceptions | No |
Java forces you to declare or catch checked exceptions, making failure modes explicit in APIs. Unchecked exceptions (RuntimeException subclasses) represent programming errors or conditions that could happen almost anywhere. Most modern frameworks prefer unchecked exceptions for cleaner APIs, but checked exceptions remain valuable for critical operations where callers must acknowledge possible failures.
C# takes a simpler approach than Java, with no distinction between checked and unchecked exceptions at the language level:
Despite its appealing name, Microsoft recommends against deriving from ApplicationException. It was intended to distinguish application from system exceptions but provided no real benefit. Instead, derive custom exceptions directly from Exception or from more specific system exceptions when appropriate.
Python's exception hierarchy emphasizes simplicity and broad categorization:
Python's key design decision: BaseException exists separately from Exception to allow certain exceptions (like KeyboardInterrupt and SystemExit) to escape except Exception blocks. This lets users interrupt programs even when there's a broad exception handler.
Best practice: Always catch Exception rather than BaseException unless you specifically need to intercept system-level signals.
JavaScript and TypeScript have a fundamentally different approach—anything can be thrown, and there's no compile-time exception tracking:
123456789101112131415161718192021222324252627282930313233343536
// JavaScript/TypeScript has a minimal built-in hierarchy // The built-in Error class and its subtypes:class Error { name: string; message: string; stack?: string;} class TypeError extends Error {} // Type-related errorsclass ReferenceError extends Error {} // Undefined variable accessclass SyntaxError extends Error {} // Parsing errors class RangeError extends Error {} // Values out of rangeclass URIError extends Error {} // URI encoding errorsclass EvalError extends Error {} // eval() errors (mostly unused) // IMPORTANT: JavaScript allows throwing ANYTHINGthrow "a string"; // Works but loses stack tracethrow 42; // Works but provides no contextthrow { error: "bad" }; // Works but not instanceof Errorthrow new Error("proper"); // Best practice: always throw Error instances // TypeScript cannot track exception types at compile time// You must use runtime checks:try { riskyOperation();} catch (error) { if (error instanceof TypeError) { // Handle type error } else if (error instanceof Error) { // Handle generic error } else { // Handle non-Error thrown values throw new Error(`Unknown error: ${String(error)}`); }}Since TypeScript can't track exception types statically, well-designed exception hierarchies become even more important. They provide the runtime type information needed for proper error handling. Always extend Error, never throw raw strings or objects, and establish clear hierarchy conventions for your project.
Regardless of language, effective base exception classes share common characteristics. Understanding these elements helps you design your own application-specific base classes that integrate cleanly with the language's exception system.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
/** * Application base exception class. * * All application-specific exceptions should extend this class, * enabling unified handling, logging, and monitoring. */public abstract class ApplicationException extends RuntimeException { private final String errorCode; private final Instant timestamp; private final Map<String, Object> context; protected ApplicationException(String message) { this(message, null, null, Collections.emptyMap()); } protected ApplicationException(String message, Throwable cause) { this(message, cause, null, Collections.emptyMap()); } protected ApplicationException( String message, Throwable cause, String errorCode, Map<String, Object> context) { super(message, cause); this.errorCode = errorCode; this.timestamp = Instant.now(); this.context = new HashMap<>(context); } /** * Machine-readable error code for programmatic handling. * Format: DOMAIN.CATEGORY.SPECIFIC (e.g., "USER.VALIDATION.EMAIL_INVALID") */ public String getErrorCode() { return errorCode; } /** * When the exception occurred. Useful for debugging and correlation. */ public Instant getTimestamp() { return timestamp; } /** * Additional structured data about the error context. * May include entity IDs, operation names, etc. */ public Map<String, Object> getContext() { return Collections.unmodifiableMap(context); } /** * Fluent method to add context without modifying constructors. */ public <T extends ApplicationException> T withContext(String key, Object value) { this.context.put(key, value); @SuppressWarnings("unchecked") T self = (T) this; return self; } /** * HTTP status code suggestion for REST API boundaries. * Subclasses override to provide appropriate codes. */ public int suggestedHttpStatus() { return 500; // Internal Server Error by default } /** * Whether this exception represents a retriable condition. * Default false; transient failure subclasses override. */ public boolean isRetriable() { return false; }}Notice how these base classes include fields like errorCode, timestamp, and context. These aren't just nice-to-haves—they're essential for production systems. Error codes enable monitoring dashboards, timestamps enable correlation, and context enables debugging. Build these capabilities into your base class so all exceptions automatically support production operations.
One of the most critical features of base exception classes is exception chaining—the ability to wrap a lower-level exception within a higher-level one while preserving the original error information. This mechanism is fundamental to layered architectures where exceptions must cross abstraction boundaries.
Consider a typical layered application: a controller calls a service, which calls a repository, which uses a database driver. When the database connection fails, you don't want the controller to catch SQLException—that would leak implementation details. But you also don't want to lose the root cause information.
Exception chaining solves this: each layer catches exceptions from below and wraps them in domain-appropriate exceptions, while preserving the original as the 'cause'.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Repository Layer: Catches database exceptions, wraps in domain termspublic class UserRepository { public User findById(UserId id) { try { return jdbcTemplate.queryForObject( "SELECT * FROM users WHERE id = ?", userRowMapper, id.value() ); } catch (EmptyResultDataAccessException e) { // Convert to domain exception - no chaining needed here throw new UserNotFoundException(id); } catch (DataAccessException e) { // Wrap infrastructure exception with cause chain throw new RepositoryException( "Failed to fetch user: " + id, e // ← Original exception preserved as cause ); } }} // Service Layer: Catches repository exceptions, wraps in service termspublic class UserService { public UserDTO getUser(String userId) { try { User user = userRepository.findById(UserId.of(userId)); return userMapper.toDTO(user); } catch (UserNotFoundException e) { // Domain exception - rethrow as-is throw e; } catch (RepositoryException e) { // Wrap in service-level exception throw new UserServiceException( "Failed to retrieve user profile", e // ← RepositoryException (with its SQLException cause) preserved ); } }} // Controller Layer: Logs full chain, returns clean response@RestControllerpublic class UserController { @GetMapping("/users/{id}") public ResponseEntity<UserDTO> getUser(@PathVariable String id) { try { UserDTO user = userService.getUser(id); return ResponseEntity.ok(user); } catch (UserNotFoundException e) { return ResponseEntity.notFound().build(); } catch (UserServiceException e) { // Log the FULL exception chain for debugging log.error("Failed to get user", e); // Print cause chain: // UserServiceException → RepositoryException → SQLException → SocketException Throwable cause = e; while (cause != null) { log.debug("Caused by: {}", cause.getClass().getName()); cause = cause.getCause(); } return ResponseEntity.internalServerError().build(); } }}SQLException from a service.Most non-trivial applications benefit from a custom base exception class that sits between the language's built-in exceptions and your specific domain exceptions. This application base class provides:
When designing your application's base exception class, you'll face several key decisions:
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Abstract vs Concrete | Abstract base (can't instantiate directly) | Concrete base (can throw directly) | Abstract — Forces creation of specific types, improves categorization |
| Checked vs Unchecked (Java) | Extend Exception (checked) | Extend RuntimeException (unchecked) | Unchecked — Cleaner APIs, but consider checked for critical operations |
| Sealed vs Open (C#) | Sealed hierarchy (no external extension) | Open hierarchy (anyone can extend) | Open — Allow modules to define their own exceptions |
| Error codes required? | Mandatory error code in constructor | Optional error code (nullable) | Required — Ensures consistent operational data |
| Mutable context? | Immutable context (set at creation) | Mutable context (add during propagation) | Mutable — Allows enrichment as exception propagates up |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
/** * Root of the application exception hierarchy. * All application-specific exceptions extend from here. */public abstract class ApplicationException extends RuntimeException { // Base implementation shown earlier...} /** * Business logic violations and domain rule failures. * HTTP 4xx family - client-correctable errors. */public abstract class BusinessException extends ApplicationException { protected BusinessException(String message) { super(message); } protected BusinessException(String message, String errorCode, Map<String, Object> context) { super(message, null, errorCode, context); } @Override public int suggestedHttpStatus() { return 400; // Bad Request by default for business errors }} /** * Infrastructure and integration failures. * HTTP 5xx family - server-side issues. */public abstract class TechnicalException extends ApplicationException { protected TechnicalException(String message, Throwable cause) { super(message, cause); } protected TechnicalException(String message, Throwable cause, String errorCode) { super(message, cause, errorCode, Collections.emptyMap()); } @Override public int suggestedHttpStatus() { return 503; // Service Unavailable by default } @Override public boolean isRetriable() { return true; // Technical failures are often transient }} /** * Security-related exceptions. * Authentication and authorization failures. */public abstract class SecurityException extends ApplicationException { protected SecurityException(String message) { super(message); } @Override public int suggestedHttpStatus() { return 403; // Forbidden by default }} // Example: Now specific exceptions extend these bases public class InsufficientFundsException extends BusinessException { private final Money requested; private final Money available; public InsufficientFundsException(Money requested, Money available) { super( String.format("Insufficient funds: requested %s but only %s available", requested, available), "PAYMENT.INSUFFICIENT_FUNDS", Map.of("requested", requested.toString(), "available", available.toString()) ); this.requested = requested; this.available = available; } @Override public int suggestedHttpStatus() { return 422; // Unprocessable Entity }} public class DatabaseConnectionException extends TechnicalException { public DatabaseConnectionException(String message, SQLException cause) { super(message, cause, "DATA.CONNECTION_FAILED"); }}Understanding what NOT to do is just as important as understanding best practices. Here are common anti-patterns in base exception class design:
ApplicationException with a type field instead of separate classesException with no intermediate organizationSQLException escape from the data layercause pointing to original123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// ❌ ANTI-PATTERN: Generic exception with type fieldpublic class GenericException extends RuntimeException { public enum Type { VALIDATION, BUSINESS, TECHNICAL, SECURITY } private final Type type; // This forces instanceof-free handling but loses type safety public GenericException(Type type, String message) { this.type = type; }} // Caller has to manually check type - no compiler helptry { process();} catch (GenericException e) { switch (e.getType()) { case VALIDATION: // handle... case BUSINESS: // handle... // Easy to miss cases, no exhaustiveness checking }} // ✅ CORRECT: Semantic exception hierarchytry { process();} catch (ValidationException e) { // Specific handling} catch (BusinessException e) { // Category handling}// Compiler ensures you're catching the right types // ❌ ANTI-PATTERN: Losing the causetry { repository.save(entity);} catch (DataAccessException e) { log.error("Save failed: " + e.getMessage()); // Only message logged! throw new ServiceException("Save failed"); // Original exception LOST} // ✅ CORRECT: Preserving the causetry { repository.save(entity);} catch (DataAccessException e) { log.error("Save failed", e); // Full exception logged throw new ServiceException("Save failed", e); // Cause preserved} // ❌ ANTI-PATTERN: Flat hierarchypublic class UserNotFoundException extends Exception {}public class InvalidPasswordException extends Exception {}public class EmailInUseException extends Exception {}public class DatabaseConnectionException extends Exception {}// No intermediate grouping - can't catch "all user errors" without catching everything // ✅ CORRECT: Organized hierarchypublic class UserNotFoundException extends UserException {}public class InvalidPasswordException extends UserException {} public class EmailInUseException extends UserException {}public abstract class UserException extends BusinessException {}// Now you can catch UserException for user-related handlingBase exception classes form the foundation of your application's error handling architecture. Getting them right enables precise catching, clean abstraction boundaries, and effective operational support.
RuntimeException vs Exception, Python's Exception vs BaseException, TypeScript's Error class.BusinessException, TechnicalException, SecurityException provide useful grouping.What's next:
With a solid understanding of base exception classes, we'll move to Custom Exception Classes—how to design specific exceptions for your domain, what information they should carry, and how to make them useful for both error handling and debugging.
You now understand the role of base exception classes in exception hierarchies, how major languages structure their built-in hierarchies, and how to design your own application base class. The foundation is set for building comprehensive, domain-specific exception types.