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.

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 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.

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

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.

Skill: where does each error go?

A transient gateway timeout should be handled by:

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.

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.

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.