Software Design Reference System Designer · Managing Complexity

Lesson 0021 · System Designer · Module 2

Practical, Maintainable Error Handling

Lesson 0014 said fail fast at the right boundary. This lesson builds the full strategy: where errors are raised, how they travel, and where they are translated, retried, and logged.

Reading ≈ 8 minPractice ≈ 45 min

Mission tie-in: "design practical and maintainable error handling strategies" — the difference between exceptions as a mechanism and exceptions as a language.

Knowledge: a language, not a mechanism

An error strategy has four layers, each with a job:

  1. Raise — in the domain, at the moment of refusal (lessons 0011, 0014).
  2. Translate — at boundaries, wrap foreign errors in your vocabulary.
  3. Retry — at integration boundaries, where failures are transient.
  4. Log and report — at the edges, once, with the error's story intact.
The principle behind the principle

An exception is an edge between the place that detects a problem and the place with the context to decide what to do about it. Those two are almost never the same, and the distance between them is the design. The domain knows what is invalid; only the adapter knows whether a failure was transient; only the edge knows who is waiting and what they can do with the answer. Handle too early and you destroy information the later decision needs; handle too late and the decision is taken by code that no longer knows what was attempted.

The four layers are therefore four kinds of knowledge, not four conventions — which is where every rule below comes from: don't catch what you can't decide about, don't erase a cause you didn't understand, don't log where you don't yet know the outcome.

Where it stops applying. In a 200-line script the raise site and the edge are twenty lines apart, so a hierarchy is ceremony. A library has no edge at all: it raises and translates, but logs nothing and retries nothing, because both take decisions that belong to its caller. And where failure is the normal path at volume — a parser over millions of rows — an exception per item costs speed and clarity both; an accumulated result is the honest shape. The example below sits on that boundary.

The language is your exception hierarchy — flat, named, and domain-shaped:

class CheckoutError(Exception):
    """Base for everything checkout can fail with."""

class InsufficientStock(CheckoutError):
    pass

class PaymentDeclined(CheckoutError):
    def __init__(self, provider_message: str) -> None:
        self.provider_message = provider_message
        super().__init__(f"payment declined: {provider_message}")

class PaymentGatewayUnavailable(CheckoutError):   # retryable, not a decline
    pass

Each layer then handles exactly what it is placed to handle. Retry belongs to the adapter, because only the adapter knows the failure was a transport problem:

# infrastructure/ — retry wraps the port, never the rule
class RetryingPaymentGateway:
    def __init__(self, inner: PaymentGateway, attempts: int = 3) -> None:
        self._inner = inner
        self._attempts = attempts

    def charge(self, amount: Money, token: str) -> str:
        for attempt in range(self._attempts):
            try:
                return self._inner.charge(amount, token)
            except PaymentGatewayUnavailable:
                if attempt == self._attempts - 1:
                    raise                         # out of attempts: let it travel
                time.sleep(2 ** attempt)          # backoff lives here, not in the domain

Translation and logging belong at the edge, where the error leaves the system and someone outside has to be told something useful:

# api/ — translate once, log once
def checkout_endpoint(cart: Cart, service: CheckoutService) -> Response:
    try:
        service.checkout(cart)
    except InsufficientStock as e:
        return Response(409, str(e))              # a client mistake
    except PaymentDeclined as e:
        return Response(402, e.provider_message)  # the bank said no
    except CheckoutError:
        logger.exception("checkout failed")       # logged once, with the traceback
        return Response(500, "checkout unavailable")
    return Response(201, "created")

The domain in the middle contains no try at all. It raises its own vocabulary and gets on with the rules — which is why its tests are three lines long.

Four rules keep it maintainable: never catch Exception bare; never convert an error to None at the raise site; catch only what you can handle, at the boundary where handling means something; log once, where the error leaves the system.

Worked example: the catch that cost a quarter's reconciliation

A four-person payments team runs a nightly reconciliation over roughly 12,000 settlement rows. Inside the row loop sits except Exception: continue. The job is green every morning.

A senior designer reads the line after the catch first. continue is a statement of policy: this failure has no consequence. So the question is not "should we catch here?" but "who is entitled to decide a settlement row doesn't matter?" Nobody in that loop has the standing, because the loop cannot see the reject rate — and the rate is the only thing separating one bad row from a broken feed. Logging inside the loop is rejected for the same reason: it turns a silent failure into an unread one and leaves the decision where it was.

Price the drift. A 0.3% failure rate is 36 rows a night, about 1,080 unreconciled settlements over a month. At a median €48 a row that is roughly €52,000 of cash the ledger cannot attribute — found by finance at quarter close, not by engineering, and reconstructed through a two-week manual audit. The defect was one line; the bill was a fortnight of someone else's quarter.

The fix moves the decision up one layer and makes the rate visible — landing on the boundary the principle box named, where an accumulated result beats an exception per row.

# application/ — the batch owns the run, so the batch decides
@dataclass(frozen=True)
class RowRejected:
    row_id: str
    reason: str

def reconcile(rows: list[dict], ledger: Ledger) -> BatchResult:
    posted, rejected = 0, []
    for row in rows:
        try:
            ledger.post(parse_settlement(row))      # raises SettlementInvalid
            posted += 1
        except SettlementInvalid as invalid:        # one named failure, handled
            rejected.append(RowRejected(row["id"], str(invalid)))
    if len(rejected) > len(rows) * 0.01:            # a broken feed, not a bad row
        raise ReconciliationAborted(posted, rejected)
    return BatchResult(posted=posted, rejected=tuple(rejected))

Three things changed and only one is code. The catch is specific, so an unexpected failure still travels. The rejects are data, so the run reports "11,964 posted, 36 rejected" to a dashboard rather than to a log nobody opens. And the threshold makes the policy reviewable. Eighteen months later, when a partner silently changed its date format, the job failed on the first night instead of the ninetieth.

† A hierarchy that's more than two levels deep is usually decoration — "checkout" and "payment" under one base is enough. Retry logic never belongs in the domain (it's coordination); it belongs at the integration adapter.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
raise MyError(str(e))The original traceback is thrown away at the boundary.raise MyError(...) from e — keep the cause.
a retry loop inside a domain methodCoordination in the business rules; the tests now sleep.Wrap the adapter (as above); the rule stays pure.
the same failure logged at three levelsOne incident, three alerts, no coherent story.Log once, where the error leaves the system.
raise Exception("bad request")Callers can catch everything or nothing — no middle.A named class inside your hierarchy.
What people get wrong after reading this
"I caught the exception, so I handled it."
Handling means changing the outcome. except: pass and except: log declare that the failure has no consequence — usually from the layer least qualified to say so.
"Wrapping an error throws away the stack trace."
Only if you drop the cause. raise InvoiceRejected(...) from original keeps the whole chain and adds your vocabulary on top. Translation preserves; raise MyError(str(e)) erases.
"Retries make a system resilient."
Only where repetition is safe. Retrying a non-idempotent write turns one timeout into two charges. A retry needs an idempotency key, or proof the failure happened before any effect — otherwise it is an outage that bills the customer twice.
"Log at every level so nothing is missed."
One incident then produces three entries at three severities and a timeline reassembled by hand. Log once at the edge with the cause chain attached: that entry already says everything the three would have.

When this is the wrong move

Skip the machinery when the layers collapse into each other. A one-file script has no boundary worth translating across. A library raises its own types and stops. Loops where failure is the expected case want an accumulated result, not an exception per item. And don't build a hierarchy before two callers actually distinguish two failures — a base class with one subclass is a naming ceremony, and the second class costs nothing to add on the day the second decision appears.

Skill: where does each error go?

A gateway times out on a charge that may already have succeeded. It should be:

An ORM raises its own database error type. At the repository boundary you:

An error should be logged exactly once, where:

Practice on your own code

Audit one module's exception handling: list every try/except, what it catches, and what it does. Mark each as raise, translate, retry, or log-and-report. Delete the bare excepts; move retries to boundaries; name the hierarchy if there isn't one.

In the lab. The phase-2 checkpoint hands you the returns-resolution service with a bare except Exception around the carrier call that quietly returns None. You classify every catch as raise, translate, retry or report, and move the retry into the adapter that can prove the call is safe to repeat.

Reveal: a layered fix

A SyncService catches Exception around an API call, prints, and continues — silent partial failure. Fixed: a SyncError base; the HTTP adapter raises SyncRetryable for 5xx (retried once by the adapter), SyncRejected for 4xx (passed through); the service logs once per failed batch with per-item causes.

Teach it back
  1. Explain to a junior, in two sentences, why the retry belongs in the adapter and not in the rule.
  2. Find one except in your own code this week whose next line is pass, continue, or a log call. Name its cost: who finds out, how late, and what they have to do about it.
  3. Predict what breaks in a year if that catch stays: what is the first symptom, and who reports it?

Your win

You can classify every error in a module into raise / translate / retry / report, build a flat domain-shaped hierarchy, and keep retries and logging out of the domain.

Read and watch deeper

Bring your module's try/except inventory to your agent-teacher and classify each catch together.