7 Exception Handling & Error Propagation — Architect-Level Deep Dive

7 Exception Handling & Error Propagation — Architect-Level Deep Dive

7.1 Exception Hierarchy and Custom Exception Design

The Throwable hierarchy:

Throwable
├── Error                          ← JVM-level failures, not meant to be caught/handled
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── VirtualMachineError
└── Exception
    ├── Checked Exceptions          ← must be declared (throws) or caught
    │   ├── IOException
    │   ├── SQLException
    │   └── (any direct Exception subclass not extending RuntimeException)
    └── RuntimeException            ← Unchecked — compiler doesn't force handling
        ├── NullPointerException
        ├── IllegalArgumentException
        ├── IllegalStateException
        └── ClassCastException

Checked vs Unchecked — the actual design intent, not just “compiler enforces vs doesn’t”:

  • Checked exceptions are meant to represent recoverable conditions external to program logic that a well-written caller should anticipate and handle — a file not existing, a network call failing, a SQL constraint violation. The compiler forcing throws or try/catch is a deliberate design contract: “this can fail in ways outside your control; decide explicitly how to respond.”
  • Unchecked exceptions (RuntimeException) represent programming errors — violated preconditions, invalid state, bugs — that callers generally can’t meaningfully recover from at the call site, only fix in the code. Forcing every caller to catch NullPointerException or IllegalArgumentException everywhere would add ceremony without adding safety.
  • Error signals conditions the application has no reasonable path to recover from at all (OutOfMemoryError, StackOverflowError) — catching these is rarely correct; they typically indicate the JVM itself is in a compromised state.

Why checked exceptions are controversial / increasingly avoided in modern API design:

  • They leak implementation details up the call stack — a method’s throws clause must be updated every time an internal dependency’s checked-exception signature changes, even through several layers of unrelated callers, producing exactly the kind of brittle coupling architects try to avoid.
  • They don’t compose well with functional interfaces (a Function<T,R>‘s apply() can’t declare throws IOException without changing the interface itself) — this is a large part of why lambda-heavy modern Java code tends to wrap checked exceptions into unchecked ones at the boundary.
  • The pragmatic consensus (reflected in most modern frameworks — Spring’s exception translation, most ORMs wrapping SQLException into unchecked exceptions) is: use unchecked exceptions for almost everything, reserving checked exceptions for the rare case where the caller has a genuinely actionable, distinct recovery path for that specific failure mode (and even then, many architects still prefer unchecked + clear documentation).

Custom exception design — practical guidelines:

public class InsufficientFundsException extends RuntimeException {
    private final String accountId;
    private final BigDecimal shortfall;

    public InsufficientFundsException(String accountId, BigDecimal shortfall) {
        super(String.format("Account %s short by %s", accountId, shortfall));
        this.accountId = accountId;
        this.shortfall = shortfall;
    }

    public String getAccountId() { return accountId; }
    public BigDecimal getShortfall() { return shortfall; }
}
  1. Extend RuntimeException by default for application/domain exceptions in modern service code — aligns with the prevailing unchecked-by-default consensus, avoids throws pollution across layers.
  2. Carry structured context as fields, not just message strings — accountId/shortfall above let calling code (or a global exception handler) make programmatic decisions and produce structured logs/responses, rather than parsing a human-readable message string (a brittle anti-pattern).
  3. Build a small, purposeful exception hierarchy reflecting your domain, not a 1:1 exception-per-error-code explosion:
public abstract class DomainException extends RuntimeException {
    public DomainException(String message) { super(message); }
    public DomainException(String message, Throwable cause) { super(message, cause); }
}
public class ResourceNotFoundException extends DomainException { ... }
public class ValidationException extends DomainException { ... }
public class InsufficientFundsException extends DomainException { ... }

This lets a global handler (e.g., a Spring @ControllerAdvice) catch the common base type for default behavior while still allowing specific handlers for distinct exception types, and lets callers catch at whatever granularity makes sense for their context.

  1. Always preserve the original cause when wrapping a lower-level exception:
try {
    repository.save(entity);
} catch (DataAccessException e) {
    throw new OrderPersistenceException("Failed to persist order " + orderId, e);  // cause chain preserved
}

Losing the cause (constructing the new exception without passing the original) destroys the stack trace needed for root-cause diagnosis — a very common and costly mistake in production incident response, where the logged exception points only to the wrapping layer with no indication of what actually failed underneath.

  1. Avoid exceptions for expected, frequent control flow. Exception construction captures a full stack trace (fillInStackTrace()) — relatively expensive — and using exceptions for routine flow (e.g., “no record found” in a tight loop) both signals incorrect intent and carries real performance cost at scale. Prefer Optional, sentinel return values, or explicit result types for expected “not found”/”no match” outcomes; reserve exceptions for genuinely exceptional conditions.
  2. Don’t catch Throwable or bare Exception indiscriminately. Catching overly broad types risks silently swallowing Errors or unrelated RuntimeExceptions that should propagate (e.g., catching Exception around a block that might throw OutOfMemoryError-adjacent issues, or masking a NullPointerException that indicates a real bug as if it were an expected business exception).

7.2 Best Practices for Logging and Error Propagation in Microservices

In a distributed system, an exception’s “story” doesn’t end at the throw site — it has to survive serialization across service boundaries, remain diagnosable from logs you didn’t write while debugging at 2am, and not leak sensitive internals to clients.

1. Structured logging, not string concatenation:

// Avoid
log.error("Failed to process order " + orderId + " for user " + userId);

// Prefer — parameterized logging (SLF4J) avoids string-building cost when log level is disabled,
// and structured fields enable log aggregation/querying (ELK, Splunk, Datadog) by field, not regex
log.error("Failed to process order orderId={} userId={}", orderId, userId, exception);

With a structured/JSON logging backend (Logstash encoder, etc.), passing key-value context as MDC fields or structured arguments — rather than embedding everything in a free-text message — is what actually makes logs queryable at scale (“show me all ERROR logs for orderId=X across all services”) instead of just readable one-at-a-time.

2. Correlation IDs — the backbone of distributed traceability:

  • Generate a unique correlation/trace ID at the system’s entry point (API gateway, or the first service to receive a request) and propagate it through every downstream call — typically via an HTTP header (X-Correlation-ID or, increasingly, W3C Trace Context’s traceparent) and via MDC (Mapped Diagnostic Context) so it’s automatically included in every log line emitted during that request’s processing, across every service it touches.
  • Without this, reconstructing the full path of a failed request across microservices means manually correlating timestamps across separate log streams — slow, error-prone, and often impossible under load.
  • Frameworks (Spring Cloud Sleuth/Micrometer Tracing, OpenTelemetry) automate propagation, but architects should verify it actually survives every hop — particularly through async boundaries (message queues, CompletableFuture/virtual-thread handoffs), since MDC context is thread-local and doesn’t automatically follow execution onto a different thread unless explicitly propagated (a frequent gap when teams adopt async processing without updating their tracing setup).

3. Don’t log and rethrow at every layer — pick one place to log, let exceptions propagate cleanly otherwise:

// Anti-pattern — same exception logged 3 times as it bubbles up through 3 layers
catch (Exception e) {
    log.error("Error in service layer", e);
    throw e;
}

This produces duplicate, noisy log entries for a single failure and obscures the actual count of distinct incidents in monitoring/alerting. The general guidance: log at the boundary where you have enough context to act meaningfully (often the outermost handler — a global exception handler at the API layer) or at the point where you’re converting the exception into something different (and adding genuinely new context), not at every pass-through layer.

4. Centralized exception translation at the API boundary:

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException e) {
        log.warn("Resource not found: {}", e.getMessage());
        return ResponseEntity.status(404).body(new ErrorResponse("NOT_FOUND", e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        log.error("Unhandled exception", e);    // full stack trace logged internally
        return ResponseEntity.status(500).body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
        // NOTE: generic message to client — internal details NOT exposed
    }
}
  • This centralizes the mapping from internal exception types → consistent, well-structured API error responses, instead of scattering try/catch-and-format logic across every controller.
  • Never expose internal exception details (stack traces, SQL fragments, internal class names) in client-facing responses — this is both an information-disclosure security concern and poor API design. Log full details server-side; return a generic, safe message plus a correlation ID the client/support team can use to look up full details in your logs.

5. Distinguish retryable vs non-retryable failures explicitly:

In a microservices context, callers (or resilience libraries like Resilience4j) need to know whether a failure is transient (network blip, momentary downstream overload — safe to retry) or permanent (bad request data, business rule violation — retrying is pointless and can even cause duplicate side effects). This is often best modeled via exception hierarchy or explicit error codes:

public class TransientServiceException extends RuntimeException { }   // safe to retry
public class PermanentServiceException extends RuntimeException { }   // retrying won't help

Wiring this into a circuit breaker / retry policy (Resilience4j’s Retry configured to retry only on specific exception types) prevents the common production failure mode of retry storms amplifying load against an already-struggling downstream service because every error — including ones that will never succeed on retry — gets blindly retried.

6. Propagating errors across service boundaries (HTTP/messaging):

  • For synchronous REST calls, use a consistent error response schema across all your services (error code, human message, correlation ID, timestamp) — letting client services parse failures programmatically rather than scraping HTTP status codes and free-text messages.
  • For async/messaging (Kafka, SQS, RabbitMQ), failures need explicit handling strategy: dead-letter queues for poison messages, idempotent retry handling (since at-least-once delivery means retries are normal, not exceptional), and ensuring the original error context (and correlation ID) travels with the failed message into the DLQ so it’s diagnosable later rather than just “a message silently vanished.”

7. Alerting discipline — not every exception should page someone:

Define severity intentionally: a single transient timeout calling a downstream service (especially one your resilience layer already retried/handled) is a WARN, not a pager-triggering ERROR. Reserve ERROR-level, alert-triggering logs for genuinely unhandled or business-impacting failures. Over-alerting on noise trains responders to ignore alerts — itself a significant operational risk in incident response maturity.