Error Handling in Designs

Domain exceptions, validation at the edges, and idempotent retries.

9 min read · lesson 37 of 41

Error handling is part of the design, not an afterthought. Interviewers notice whether your design says what happens when the payment fails, the slot is taken, or the input is nonsense.

The idea

A good hospital triage desk sorts problems quickly: a paperwork issue goes back to the patient to fix, a capacity issue gets "please wait or try another branch", and a genuine emergency escalates immediately. Errors in software need the same triage:

CategoryExampleResponse
Invalid inputNegative quantity, end before startReject at the boundary, tell the caller exactly what's wrong
Business rule violationSeat already booked, insufficient balanceDomain exception with a clear meaning
Transient infrastructureTimeout, connection resetRetry with backoff, then fail cleanly
Bug / invariant brokenNegative stock after a decrementFail loudly; don't hide it

Why it matters

Mixing these categories leads to designs that retry business errors forever, swallow bugs, or show users stack traces. A clear exception hierarchy lets callers handle what they understand and let the rest propagate. Combined with idempotency, it makes retries safe.

python
import randomimport timefrom typing import Callable, TypeVarT = TypeVar("T")class DomainError(Exception):    """Base for business-rule failures callers are expected to handle."""class OutOfStock(DomainError):    def __init__(self, sku: str, requested: int, available: int) -> None:        super().__init__(f"{sku}: requested {requested}, available {available}")        self.sku, self.requested, self.available = sku, requested, availableclass TransientError(Exception):    """Infrastructure hiccup; safe to retry if the operation is idempotent."""def with_retry(op: Callable[[], T], attempts: int = 4, base_delay: float = 0.05) -> T:    for attempt in range(attempts):        try:            return op()        except TransientError:            if attempt == attempts - 1:                raise            time.sleep(base_delay * 2 ** attempt * random.uniform(0.5, 1.5))  # jitter    raise AssertionError("unreachable")class Inventory:    def __init__(self, stock: dict[str, int]) -> None:        self._stock = stock    def reserve(self, sku: str, qty: int) -> None:        if qty <= 0:            raise ValueError("qty must be positive")             # invalid input        available = self._stock.get(sku, 0)        if qty > available:            raise OutOfStock(sku, qty, available)                # business rule        self._stock[sku] = available - qty        assert self._stock[sku] >= 0, "invariant broken"         # bug guardinv = Inventory({"tshirt-m": 2})try:    with_retry(lambda: inv.reserve("tshirt-m", 3))except OutOfStock as e:    print(f"tell user: only {e.available} left")

Note that with_retry only retries TransientError; retrying OutOfStock would be pointless.

When to use / when not to

Define a small domain exception hierarchy for any non-trivial LLD problem, validate at the boundaries (constructors, public methods), and retry only transient, idempotent operations. For multi-step workflows (reserve stock, charge, confirm), plan compensating actions: if charging fails, release the reservation.

Common mistakes

  • except Exception: pass, which hides bugs.
  • Retrying non-idempotent operations, causing double charges.
  • Returning False or None with no reason, forcing callers to guess.
  • Leaking infrastructure exceptions (botocore.ClientError) into domain code; translate at the adapter.
  • No cleanup on partial failure, leaving held seats or reserved stock forever.

In the interview

Q: How does your design handle payment failure after seats are held? The booking stays HELD until the hold expires; on explicit failure we release the hold immediately (compensating action) and the user can retry.

Q: When do you retry? Only for transient failures on idempotent operations, with exponential backoff, jitter, and a cap. Business errors are surfaced, not retried.

Q: Where do validation errors get raised? At the boundary: value object constructors and public service methods, so the core logic can assume valid data.

Key takeaways

  • Classify errors: invalid input, business rule, transient, bug.
  • Use a domain exception hierarchy; translate infrastructure errors at adapters.
  • Retry only transient errors on idempotent operations, with backoff and jitter.
  • Plan compensating actions for multi-step workflows.
Complete & continue