Chapter 08 · Article 39 of 55
Exception Handling in Low-Level Design
Exception handling is not an afterthought - it is a first-class design concern in robust software systems. In Low-Level Design (LLD), how a system handles failure paths determin…
Article outline15 sections on this page+
Overview
Exception handling is not an afterthought - it is a first-class design concern in robust software systems. In Low-Level Design (LLD), how a system handles failure paths determines its reliability, debuggability, and maintainability far more than its happy-path logic.
Well-designed exception handling achieves three goals:
- Separation of concerns - Error detection is decoupled from error handling. The code that discovers a problem need not know how to resolve it.
- Fail-fast semantics - Invalid states are caught early at system boundaries, preventing corruption from propagating deeper into the system.
- Operational visibility - Exceptions carry context (what failed, why, and where) that enables rapid diagnosis in production.
In LLD interviews and real systems alike, exception handling strategy reveals architectural maturity. A service that silently swallows errors or leaks implementation details through its API is a liability. A service with a clear exception hierarchy, proper translation between layers, and meaningful error context is production-ready.
The cost of poor exception handling compounds over time. Silent failures lead to data corruption that surfaces days later. Leaked stack traces become security vulnerabilities. Missing context in error messages turns a 5-minute debugging session into a multi-hour investigation. Designing exception handling upfront - deciding what can fail, how failures are classified, and where they are handled - is as important as designing the happy path.
Exception handling also defines the contract between layers. A repository layer should not throw HTTP exceptions. A controller should not expose database constraint names. Each layer translates exceptions into the vocabulary of its caller, maintaining encapsulation and enabling independent evolution of components.
Exception Hierarchy
Most object-oriented languages follow a hierarchy rooted in a base throwable type. Here is the canonical structure (Java-inspired, applicable conceptually to C#, Python, C++):
Throwable
/ \
Error Exception
/ \ / \
OutOfMemory StackOverflow RuntimeException IOException
VirtualMachine / \ (Checked)
Error NullPointer IllegalArgument
(Unrecoverable) (Unchecked) (Unchecked)
Level Breakdown
| Level | Purpose | Recovery? |
|---|---|---|
| Throwable | Root of all throwable objects. Carries message + stack trace. | - |
| Error | JVM/system-level failures (OOM, StackOverflow). Application code should NOT catch these. | No |
| Exception | Application-level failures that code can anticipate and potentially recover from. | Yes |
| RuntimeException | Programming errors (null dereference, bad cast, illegal argument). Unchecked. | Depends |
| Checked Exception | Recoverable conditions external to the program (file not found, network timeout). Compiler-enforced handling. | Yes |
Design principle: Your custom exceptions should extend Exception (checked) when callers can reasonably recover, or RuntimeException (unchecked) when the error indicates a programming bug or unrecoverable state.
Checked vs Unchecked Exceptions
| Aspect | Checked Exceptions | Unchecked Exceptions |
|---|---|---|
| Enforcement | Compile-time (must catch or declare) | Runtime only |
| When to use | Recoverable external failures (I/O, network, parsing) | Programming errors, invariant violations |
| Caller expectation | Caller is expected to handle or propagate | Caller is not forced to handle |
| Examples | IOException, SQLException, TimeoutException | NullPointerException, IllegalStateException |
| API contract | Part of the method signature | Not part of the signature |
| Overhead | Forces handling code even when recovery is impossible | Can be missed silently |
| Modern trend | Declining in favor of unchecked + Result types | Preferred in Spring, modern frameworks |
Guideline: Use checked exceptions only when the caller can take a meaningful recovery action. If the only response is to log and rethrow, an unchecked exception is more appropriate.
Key Concepts & Theory
Exception Propagation
When an exception is thrown, the runtime searches the call stack upward for a matching handler. If no handler is found, the thread terminates (or the global handler is invoked). This "bubbling" behavior means exceptions naturally flow from low-level code to high-level orchestrators.
Stack Unwinding
As the exception propagates, each stack frame is destroyed. In languages with deterministic destruction (C++, Rust), destructors run during unwinding. In garbage-collected languages (Java, C#), finally blocks execute. Resource leaks occur when unwinding skips cleanup.
Exception Safety Guarantees
Borrowed from C++ but universally applicable:
| Guarantee | Definition | Example |
|---|---|---|
| No-throw | Operation will never throw. Always succeeds. | Swap, move operations, destructors |
| Strong (commit-or-rollback) | If exception occurs, state is rolled back to pre-call state. | Database transactions, copy-and-swap |
| Basic | If exception occurs, invariants are preserved but state may have changed. | Partial container modification |
| No guarantee | Exception may leave object in invalid state. | Legacy code, poorly designed APIs |
Design target: Public APIs should provide at least the basic guarantee. Critical operations (payments, state transitions) should provide the strong guarantee. Achieving the strong guarantee typically involves the "do work on a copy, then swap" idiom - perform all fallible operations on temporary state, and only commit (via a no-throw swap) when everything succeeds. This is directly analogous to database transactions with commit/rollback semantics.
Custom Exception Design
When to Create Custom Exceptions
- When you need to distinguish between failure modes programmatically (not just by message)
- When exceptions cross layer boundaries and need translation
- When you need to carry domain-specific context (order ID, user ID, validation details)
- When a generic exception would force callers to parse message strings
Hierarchy Design
ApplicationException (base, unchecked)
├── ValidationException
│ ├── InvalidEmailException
│ └── MissingFieldException
├── BusinessRuleException
│ ├── InsufficientBalanceException
│ └── DuplicateOrderException
└── InfrastructureException
├── DatabaseException
└── ExternalServiceException
Pseudocode: Custom Exception Classes
class ApplicationException extends RuntimeException:
field errorCode: String
field context: Map<String, Object>
field timestamp: DateTime
constructor(message, errorCode, cause):
super(message, cause)
this.errorCode = errorCode
this.context = new HashMap()
this.timestamp = DateTime.now()
method withContext(key, value):
this.context.put(key, value)
return this // fluent API
class ValidationException extends ApplicationException:
field violations: List<FieldViolation>
constructor(violations):
super("Validation failed: " + violations.size() + " violations", "VALIDATION_ERROR", null)
this.violations = violations
class FieldViolation:
field fieldName: String
field rejectedValue: Object
field message: String
Exception Handling Best Practices
| DO | DON'T |
|---|---|
| Catch the most specific exception type | Catch generic Exception or Throwable |
| Include context: what operation, what input, what state | Throw exceptions with empty or generic messages |
| Use exceptions for exceptional conditions only | Use exceptions for flow control (e.g., checking existence) |
| Fail fast at system boundaries (validate inputs early) | Let invalid data propagate deep into the system |
| Log at the appropriate level (ERROR for unexpected, WARN for recoverable) | Log every exception at ERROR level |
Clean up resources in finally or use try-with-resources | Rely on GC to close connections/streams |
| Translate exceptions at layer boundaries | Leak SQLException through your REST API |
| Preserve the original cause when wrapping | Discard the cause chain (new AppException(msg) without cause) |
| Document exceptions in method contracts | Throw undocumented exceptions that surprise callers |
| Use error codes for machine-readable classification | Rely solely on exception class names for programmatic handling |
Pseudocode Implementation
Service Layer with Proper Exception Handling
class UserService:
field userRepository: UserRepository
field emailService: EmailService
field validator: UserValidator
method createUser(request: CreateUserRequest) -> User:
// 1. Validation at boundary
violations = validator.validate(request)
if violations.isNotEmpty():
throw new ValidationException(violations)
// 2. Business rule check
existingUser = userRepository.findByEmail(request.email)
if existingUser != null:
throw new DuplicateEntityException("User")
.withContext("email", request.email)
// 3. Infrastructure operation with translation
try:
user = User.from(request)
savedUser = userRepository.save(user)
catch DatabaseTimeoutException as e:
throw new InfrastructureException("Failed to persist user", "DB_TIMEOUT", e)
.withContext("email", request.email)
catch DatabaseConstraintViolation as e:
throw new BusinessRuleException("User conflicts with existing record", "DUPLICATE", e)
// 4. Non-critical operation - log and continue
try:
emailService.sendWelcome(savedUser)
catch ExternalServiceException as e:
logger.warn("Welcome email failed for user={}, will retry async", savedUser.id, e)
eventBus.publish(new RetryEmailEvent(savedUser.id))
return savedUser
Exception Translation at Controller Layer
class UserController:
field userService: UserService
method handleCreateUser(httpRequest) -> HttpResponse:
try:
request = parseBody(httpRequest, CreateUserRequest.class)
user = userService.createUser(request)
return HttpResponse.created(user)
catch ValidationException as e:
return HttpResponse.badRequest(ErrorBody(e.errorCode, e.violations))
catch DuplicateEntityException as e:
return HttpResponse.conflict(ErrorBody(e.errorCode, e.message))
catch InfrastructureException as e:
logger.error("Infrastructure failure in createUser", e)
return HttpResponse.serviceUnavailable(ErrorBody("SERVICE_ERROR", "Please retry"))
catch ApplicationException as e:
logger.error("Unexpected application error", e)
return HttpResponse.internalError(ErrorBody("INTERNAL_ERROR", "Something went wrong"))
Exception Handling Patterns
1. Retry Pattern
Retry transient failures (network blips, lock contention) with exponential backoff:
method executeWithRetry(operation, maxAttempts, backoffMs):
for attempt in 1..maxAttempts:
try:
return operation.execute()
catch TransientException as e:
if attempt == maxAttempts:
throw new RetriesExhaustedException(e)
sleep(backoffMs * 2^(attempt - 1))
2. Fallback Pattern
Provide degraded functionality when primary path fails:
method getUserProfile(userId):
try:
return profileService.fetch(userId)
catch ServiceUnavailableException:
return cachedProfileStore.get(userId) // stale but available
3. Circuit Breaker
Prevent cascading failures by short-circuiting calls to a failing dependency. When failure rate exceeds a threshold, the circuit "opens" and fails fast without attempting the call. The circuit transitions through three states: Closed (normal operation, failures counted), Open (all calls fail immediately with CircuitOpenException), and Half-Open (limited trial calls to test recovery). See Resilient Systems Design for full treatment.
4. Exception Shielding
Never expose internal exception details to external clients. Translate implementation-specific errors into safe, generic responses:
// BAD: "com.mysql.jdbc.exceptions.MySQLSyntaxErrorException: Table 'users' doesn't exist"
// GOOD: {"error": "SERVICE_ERROR", "message": "Unable to process request"}
5. Exception Wrapping (Chaining)
Preserve root cause while translating to domain-appropriate exceptions:
catch SQLException as e:
throw new RepositoryException("Failed to save user", e) // 'e' preserved as cause
Error Codes vs Exceptions vs Result Types
| Aspect | Error Codes | Exceptions | Result Types (Either/Optional) |
|---|---|---|---|
| Flow control | Explicit if/else checking | Implicit propagation | Explicit via map/flatMap |
| Forgettable | Yes (return value ignored) | No (propagates if unhandled) | Depends on language enforcement |
| Performance | Cheapest | Expensive (stack trace capture) | Cheap |
| Composability | Poor | Moderate | Excellent (monadic chaining) |
| Context richness | Limited (integer/enum) | Rich (message + cause + stack) | Rich (typed error channel) |
| Best for | C, system calls, FFI boundaries | OOP languages, exceptional conditions | Functional languages, expected failures |
| Modern trend | Legacy | Still dominant in Java/C# | Growing (Rust Result, Kotlin sealed classes) |
Recommendation: Use exceptions for truly exceptional conditions. Use Result/Either types for expected failure paths (validation, parsing, lookups). Reserve error codes for cross-process boundaries (HTTP status codes, gRPC status).
Real-World Examples
HTTP Error Mapping
ExceptionToHttpStatus mapping:
ValidationException → 400 Bad Request
AuthenticationException → 401 Unauthorized
AuthorizationException → 403 Forbidden
EntityNotFoundException → 404 Not Found
DuplicateEntityException → 409 Conflict
RateLimitException → 429 Too Many Requests
InfrastructureException → 503 Service Unavailable
Unhandled Exception → 500 Internal Server Error
Database Exception Translation
class JpaRepositoryAdapter implements UserRepository:
method save(user):
try:
return entityManager.persist(user)
catch PersistenceException as e:
if e.cause instanceof ConstraintViolationException:
throw new DuplicateEntityException("User", e)
if e.cause instanceof LockTimeoutException:
throw new TransientException("Database contention", e)
throw new RepositoryException("Unexpected persistence failure", e)
Validation Error Aggregation
Collect all validation errors rather than failing on the first one:
method validate(request) -> List<FieldViolation>:
violations = new ArrayList()
if request.email is blank:
violations.add(FieldViolation("email", null, "Email is required"))
if request.email not matches EMAIL_REGEX:
violations.add(FieldViolation("email", request.email, "Invalid email format"))
if request.age < 0 or request.age > 150:
violations.add(FieldViolation("age", request.age, "Age must be between 0 and 150"))
return violations
Constraints & Edge Cases
Exception in Constructors
If a constructor throws, the object is partially constructed. In languages without deterministic destruction, resources acquired before the throw are leaked. For example, if a constructor opens a file handle, then fails on a subsequent operation, that handle is never closed because no destructor/finalizer runs for an incomplete object. Solution: Use factory methods or builder patterns that validate before allocating resources. Alternatively, use two-phase initialization where the constructor only assigns fields and a separate init() method performs fallible operations.
Exception in Finally Blocks
If both try and finally throw, the original exception is typically lost (suppressed). Solution: Use try-with-resources (Java) or context managers (Python) which handle suppressed exceptions properly. If manual, catch in finally and add as suppressed.
Swallowed Exceptions
// DANGEROUS: Silent failure
try:
riskyOperation()
catch Exception:
// empty - bug hides here forever
Every catch block must either: (a) handle the error, (b) rethrow, (c) throw a translated exception, or (d) log with sufficient context. Empty catch blocks are defects.
Exception Performance Cost
Stack trace capture is expensive (walks the entire call stack). In hot paths:
- Avoid exceptions for expected conditions (use
Optional/null checks instead) - Consider pre-allocated exception instances for flow control in extreme cases (e.g.,
NonLocalReturn) - In Java,
-XX:-OmitStackTraceInFastThrowcontrols JVM optimization of repeated exceptions
Benchmark reference: Throwing an exception is ~100-1000x slower than a normal return in Java/C#.
Interview Follow-ups
Q1: How would you handle partial failures in a batch operation?
A: Collect results per item using a BatchResult<T> that contains both successes and failures. Process all items, accumulate errors with their indices/identifiers, and return the aggregate result. The caller decides whether partial success is acceptable. For critical operations, wrap the entire batch in a transaction for atomicity.
Q2: How do you prevent exception handling from cluttering business logic?
A: Use aspect-oriented programming (AOP) or middleware/interceptors for cross-cutting exception translation. In the service layer, only catch exceptions you can meaningfully handle. Let others propagate to a global exception handler (e.g., @ControllerAdvice in Spring, error middleware in Express) that maps them to appropriate responses.
Q3: When would you choose Result types over exceptions?
A: When failure is an expected, frequent outcome rather than an exceptional condition. Parsing user input, looking up optional data, and validating business rules are all cases where failure is normal. Result types make the failure path explicit in the type signature, force callers to handle both cases, and avoid the performance cost of stack trace capture.
Q4 (Hint only): How would you design exception handling for an event-driven/async system?
Hint: Consider dead-letter queues, poison pill detection, and the fact that exceptions cannot propagate across async boundaries. Think about compensation/saga patterns for distributed failures.
Q5 (Hint only): How do you test exception handling paths?
Hint: Think about fault injection, mocking dependencies to throw, verifying that the correct exception type and context are produced, and testing that resources are properly cleaned up even when exceptions occur.
Counter Questions to Ask Interviewer
- "What are the failure modes of the external dependencies in this system?" - Reveals whether you need retry, fallback, or circuit breaker patterns.
- "Is partial failure acceptable, or do we need all-or-nothing semantics?" - Determines transaction boundaries and compensation strategies.
- "What observability infrastructure exists (structured logging, distributed tracing)?" - Influences how much context to embed in exceptions vs. rely on correlation IDs.
- "Are there SLA requirements that constrain how long we can retry or wait?" - Shapes timeout and retry configuration.
- "How are errors communicated to end users vs. operators?" - Determines exception shielding strategy and error response format.
References & Whitepapers
- Effective Java, 3rd Edition (Joshua Bloch) - Items 69-77: Use exceptions only for exceptional conditions, use checked exceptions for recoverable conditions, favor standard exceptions, document all exceptions, include failure-capture information, strive for failure atomicity.
- Clean Code (Robert C. Martin) - Chapter 7: Error Handling. Prefer exceptions to return codes, write try-catch-finally first, provide context with exceptions, define exception classes in terms of a caller's needs.
- Release It! (Michael Nygard) - Stability patterns: Circuit Breaker, Bulkhead, Timeout. How exceptions interact with system stability.
- Patterns of Enterprise Application Architecture (Martin Fowler) - Exception hierarchy design in layered architectures.
- Microsoft .NET Framework Design Guidelines - Exception design guidelines, choosing between exceptions and return values.