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:
- Raise — in the domain, at the moment of refusal (lessons 0011, 0014).
- Translate — at boundaries, wrap foreign errors in your vocabulary.
- Retry — at integration boundaries, where failures are transient.
- 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.
| You see this | What it costs | The 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 method | Coordination in the business rules; the tests now sleep. | Wrap the adapter (as above); the rule stays pure. |
| the same failure logged at three levels | One 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
- Clean Code, Martin — ch. 7 "Error Handling": the exception-hierarchy and boundary-translation rules.
- A Philosophy of Software Design, Ousterhout — ch. 10 "Define Errors Out of Existence": design so many errors never arise.
- Robust Python, Viafore — Python-specific exception design: when to raise, subclass, and wrap.
- Watch: ArjanCodes YouTube — search "error handling python".
- Next: lesson 0022 — organizing modules and folders.
- Reference: Patterns — Decorator, the shape the retrying gateway above is; glossary — fail-fast.
Bring your module's try/except inventory to your agent-teacher and classify each catch together.