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:

  1. Separation of concerns - Error detection is decoupled from error handling. The code that discovers a problem need not know how to resolve it.
  2. Fail-fast semantics - Invalid states are caught early at system boundaries, preventing corruption from propagating deeper into the system.
  3. 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

LevelPurposeRecovery?
ThrowableRoot of all throwable objects. Carries message + stack trace.-
ErrorJVM/system-level failures (OOM, StackOverflow). Application code should NOT catch these.No
ExceptionApplication-level failures that code can anticipate and potentially recover from.Yes
RuntimeExceptionProgramming errors (null dereference, bad cast, illegal argument). Unchecked.Depends
Checked ExceptionRecoverable 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

AspectChecked ExceptionsUnchecked Exceptions
EnforcementCompile-time (must catch or declare)Runtime only
When to useRecoverable external failures (I/O, network, parsing)Programming errors, invariant violations
Caller expectationCaller is expected to handle or propagateCaller is not forced to handle
ExamplesIOException, SQLException, TimeoutExceptionNullPointerException, IllegalStateException
API contractPart of the method signatureNot part of the signature
OverheadForces handling code even when recovery is impossibleCan be missed silently
Modern trendDeclining in favor of unchecked + Result typesPreferred 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:

GuaranteeDefinitionExample
No-throwOperation 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
BasicIf exception occurs, invariants are preserved but state may have changed.Partial container modification
No guaranteeException 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

DODON'T
Catch the most specific exception typeCatch generic Exception or Throwable
Include context: what operation, what input, what stateThrow exceptions with empty or generic messages
Use exceptions for exceptional conditions onlyUse 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-resourcesRely on GC to close connections/streams
Translate exceptions at layer boundariesLeak SQLException through your REST API
Preserve the original cause when wrappingDiscard the cause chain (new AppException(msg) without cause)
Document exceptions in method contractsThrow undocumented exceptions that surprise callers
Use error codes for machine-readable classificationRely 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

AspectError CodesExceptionsResult Types (Either/Optional)
Flow controlExplicit if/else checkingImplicit propagationExplicit via map/flatMap
ForgettableYes (return value ignored)No (propagates if unhandled)Depends on language enforcement
PerformanceCheapestExpensive (stack trace capture)Cheap
ComposabilityPoorModerateExcellent (monadic chaining)
Context richnessLimited (integer/enum)Rich (message + cause + stack)Rich (typed error channel)
Best forC, system calls, FFI boundariesOOP languages, exceptional conditionsFunctional languages, expected failures
Modern trendLegacyStill 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:-OmitStackTraceInFastThrow controls 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

  1. "What are the failure modes of the external dependencies in this system?" - Reveals whether you need retry, fallback, or circuit breaker patterns.
  2. "Is partial failure acceptable, or do we need all-or-nothing semantics?" - Determines transaction boundaries and compensation strategies.
  3. "What observability infrastructure exists (structured logging, distributed tracing)?" - Influences how much context to embed in exceptions vs. rely on correlation IDs.
  4. "Are there SLA requirements that constrain how long we can retry or wait?" - Shapes timeout and retry configuration.
  5. "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.