Error Modeling
Failure is part of the design, not an appendix to it. Expected business failure, validation failure, dependency failure and programming bug are four categories with four different correct responses.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Which failures belong in my domain model, which are translation, and which are simply bugs I must never handle?
"Charge the customer." The ticket says nothing about the card being declined, the postcode being malformed, the payment provider timing out, or a null arriving where a total was expected.
Throw on anything that goes wrong and catch it once at the top: log, return 500. One way in, one way out, uniform across every handler, and it took ten minutes to build.
The first new requirement is "retry payment failures automatically". Someone now has to decide which of the things arriving at that one catch block are safe to retry — and the information needed to decide was destroyed at the moment everything became Error.
- The first new requirement is "retry payment failures automatically". Someone now has to decide which of the things arriving at that one catch block are safe to retry — and the information needed to decide was destroyed at the moment everything became
Error. - Then comes "tell the customer why the card was declined", which needs a decline reason the uniform handler discarded two layers ago.
- A
TypeErrorin our own code and a routine decline from the bank land in the same handler at the same log level, so the alert that should have paged someone is buried under three thousand declines a day — and the ratio gets worse as the business grows, which means the design degrades with success. - Six months later finance asks for a report splitting declines from provider outages. The data to answer it was never written down, because at the point where it was known the code had already decided the two were the same thing.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The payment provider is third-party: it can be slow, it can answer twice, and the set of codes it returns changes without a release note.
- Finance needs a declined charge and a failed charge to be distinguishable in the ledger months later, so the distinction has to survive into storage, not just into a log line.
- The service already has middleware that turns anything thrown into a 500, and hundreds of handlers rely on it, so the model has to arrive incrementally.
- A customer is never charged twice for one payment intent, whichever failure path ran (Idempotency by Design).
- Every failure a user sees is either something they can act on, or an honest admission that it is ours.
- A programming bug is never recorded as a business outcome — a null dereference must not be remembered in the ledger as "payment failed".
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The domain owns naming the failures that are part of the business. A decline is an outcome of charging, not an accident that befell it.
- The edge owns translation: domain outcome to status code, message, log level and metric (Error Boundaries).
- Nobody owns "bug". Bugs are not handled; they are surfaced with as much context as possible, counted separately, and fixed.
- The adapter around a third-party client owns collapsing that vendor's changing error list into our small, stable set (Anti-Corruption Layer).
- The first seam is "was this outcome anticipated by the domain?" Anticipated outcomes are values the domain returns; unanticipated ones are defects that propagate.
- The second seam is where untrusted input becomes a domain value. Validation failure is created there, once, and inside that line the value is trusted — re-validating downstream is how a single rule ends up in four places (Invariant Leaks).
- The third seam is every process boundary. A dependency failure is manufactured at the call to the outside world and must not be allowed to keep the vendor's vocabulary once it is past the adapter.
Four failures, four responses
The categories are not a naming convention. Each one differs on the questions that actually decide what code does: whose fault is it, does retrying help, is the user able to act, and does it belong in the business record.
Read the "must never" column first. Most production error-handling bugs are a category getting the response belonging to a different one — a bug being retried, a decline paging an engineer, a timeout being reported to the customer as though their card were bad.
- The four differ in response, which is why they must differ in type — anything less is a comment (Making Illegal States Unrepresentable).
- Only one of them is an emergency, and it is the rarest one, which is exactly why merging them destroys the alert (Logging at Boundaries).
- Two of them belong in the business record and two do not. That decision has to be made before storage, not after (Explicit State).
| Category | Example | Whose fault | Retry helps? | Correct response | Must never |
|---|---|---|---|---|---|
| Expected business failure | Card declined; insufficient balance; coupon expired | Nobody's — the domain said no | No | Return it as a value; record it; tell the user what to do next | Be logged as an error or counted in the error rate |
| Validation failure | Postcode malformed; quantity is -3 | The caller's | Not without a change | Reject at the edge, naming the field, before any domain object exists | Be discovered halfway through a write |
| Dependency failure | Provider timed out; connection reset; 503 | The outside world's | Often, if the operation is idempotent | Retry with backoff, degrade, or fail honestly with a request id | Be reported to the user as their mistake |
| Programming bug | Undefined is not a function; impossible state reached | Ours | No — it will fail identically | Propagate, capture context, alert, fix | Be caught by a broad handler and retried |
What the uniform catch destroys
The naive version is not badly written and it is not lazy — it is genuinely uniform, and uniformity is a real virtue. The problem is that it performs a lossy compression at the exact point where the information is cheapest to keep, and the loss is invisible until something needs the discarded bits.
Notice that the second version is not "more error handling". It has the same number of failure paths; it just refuses to merge them before anyone has decided they are the same.
async function charge(order: Order) {
try {
const res = await stripe.charge(order.total, order.card)
return res.id
} catch (e) {
logger.error('charge failed', e)
throw new Error('Payment failed')
}
}
// Reaching the caller: one string.
// Gone: the decline code, whether it was a timeout,
// whether it was our bug, and whether retrying is safe.type ChargeResult =
| { kind: 'charged'; id: ChargeId }
| { kind: 'declined'; reason: DeclineReason }
| { kind: 'unavailable'; dependency: 'stripe'; waitedMs: number }
async function charge(order: Order): Promise<ChargeResult> {
const res = await stripeAdapter.charge(order) // maps vendor -> our kinds
return res
}
// A TypeError inside stripeAdapter is NOT one of these.
// It propagates, because it is a bug and not an outcome.The caller can now be forced by the compiler to decide what a decline means, and the retry policy can ask a structural question instead of matching on a message. The cost is real: charge no longer has a single happy return, every caller grows a switch, and the union is now a contract that other modules depend on (Result Types).
Priced: "retry transient payment failures"
This is the change that always comes, and it is the one that reveals whether the original design kept enough information. It is worth pricing before writing either version, because the difference is not in how much code gets written — it is in how much has to be *read* and how confidently the result can be trusted.
Payment attempts that failed because the provider was unreachable or slow should be retried up to three times with backoff. Declines must not be retried, and bugs must not be hidden by the retry.
The decision has to be reconstructed from message text, so the change ships as a substring match and a list of vendor strings. It is untestable against failures nobody has seen yet, and it silently starts retrying bugs the day one of them produces a message containing "connection".
One switch decides retryability from a kind, and the adapter test pins the vendor mapping. The bug case is not reachable from the retry path at all, because a TypeError is never a member of the union.
'requires_authentication' as a fifth outcome breaks all six at compile time and cannot be shipped behind a flag as easily as adding another if. There is also a genuine loss of uniformity — the one place that used to handle all failures is now three places that each handle their own, and a new engineer has to learn which is which.How to build it
Most important first.
- Start from the outcomes, not the exceptions. Ask what can happen when this operation runs, in the language finance and support use: charged, declined, needs a different card, we could not tell. Those are your domain failures.
- Give each category the response it actually needs: business failure is returned, validation failure is rejected at the edge with a field, dependency failure is retried or degraded, bug is propagated and paged.
- Make the category structural, not a string. A
kindfield on a closed union survives serialization, storage and six months; a message does not (An Error Taxonomy That Survives Contact). - Keep the category attached to the data that justifies it — a decline carries its code, a timeout carries which dependency and how long (Debuggability by Design).
- Translate once, at the boundary, and nowhere else. Every extra translation point is a place the category can be flattened by accident.
- Do not model failures you have never seen. Four categories is a model; twenty-six subclasses of
PaymentErroris a taxonomy nobody will keep accurate (Premature Abstraction).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: "retry only transient failures" costs an archaeology phase — read every throw site reachable from charging, guess which are transient, and ship a
message.includes('timeout')check that breaks when the vendor rewords the string. The cost scales with the number of throw sites and is paid again for every future policy question. - After: the same change is one
switchin the retry policy over a closed union, and the compiler lists the cases. Adding a fifth category later costs one exhaustiveness error per switch — annoying, and exactly the annoyance you want. - What did not get cheaper: changing the *shape* of a category — adding a required field to
DependencyFailure— still touches every construction site, because the union is a contract and widening it is a contract change (Backward Compatibility as a Constraint).
- Every call site now has to say what it does with each category. That is more code than
try/catchand it is the code the compiler can check — but it is genuinely more typing, and in a thin CRUD handler it buys nothing. - A closed union is a contract: adding a category is a breaking change for every consumer, which is a feature at compile time and a real cost across a versioned boundary.
- Four categories is a simplification. Real systems have failures that are two at once — a validation error discovered only by the provider — and the model forces a choice about where those live.
What can go wrong
- The categories become five, then nine, then a hierarchy, and nobody can say which one a new failure is. The model was supposed to reduce decisions and now it adds one per failure.
- A bug gets caught by a broad
catchwritten for dependency failures and is retried three times, turning one exception into three and hiding it entirely. - The split is made in code but not in storage, so the ledger still cannot distinguish decline from outage and the reporting requirement is unmet despite the refactor.
- The mitigation itself fails: a beautifully modelled error union is thrown away by one legacy handler that still does
catch (e) { return 500 }, and the module's guarantees stop at that file.
- The domain depends on nothing to express its failures — the union is plain data, so it can be returned across any boundary and stored.
- The edge depends on the domain's categories, never the reverse: HTTP status codes must not appear in the payment module (Dependency Direction).
- The adapter depends on the vendor SDK and is the only thing that does, which is what makes "the provider added a new error code" a one-file change.
- "So we should never throw." No. Bugs should propagate, and an exception is the right mechanism for that. The categories differ in whether they are *anticipated*, not in which language feature carries them (Exceptions, Where They Help and Where They Hide the Flow).
- "This is just an error enum." The enum is the visible part; the design decision is that the four categories get four different *responses*, and that the response is decided once at a boundary rather than at every catch.
- "Validation failure is a business failure." They are separable and it matters: validation is about a message that never should have been accepted, business failure is about a well-formed request the domain refused. They differ in whose fault it is, whether retrying can help, and whether they belong in the ledger.
- "Model every failure the provider can return." That list is theirs, it changes, and most of it collapses to the same response on our side. The adapter exists precisely so their taxonomy does not become ours.
- primitive-obsession
- swallowed-errors
Testing it, and how it ages
- Test the domain function returns a decline as a value, with no HTTP and no database in the test. If that is hard, the categories are not really in the domain (Testing as Design Feedback).
- Test the adapter maps each vendor error you have actually observed to a category, from recorded fixtures — this is the test that catches vendor drift.
- Test the edge translation separately: category in, status code and body out. It needs no payment provider at all.
- One test that a thrown
TypeErroris *not* caught by the dependency-failure handler. This is the assertion teams skip and the one that keeps bugs visible.
- The categories stay stable for years; what churns is the vendor mapping, which is why it lives alone in the adapter.
- A second payment provider arrives and the union does not move — that is the payoff, and it is the moment to check whether the categories were domain language or vendor language wearing a costume.
- It stops being right when one category grows so many downstream branches that it is clearly two things. Split it then, driven by an actual divergence in handling, not by anticipation (The Rule of Three).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe four-way split is about who caused a failure and what can help, which is a property of the situation rather than of a language — a Go function returning error values and a Java method throwing face the same question about which failures were anticipated.
- DOMAIN-SPECIFICIn a domain where failure is itself the product — payments, claims, shipping — business failures outnumber successes and deserve first-class modelling. In an internal admin tool where every failure is genuinely a bug, three of the four categories are empty and the uniform 500 handler is the correct design.
- SIMPLIFIEDFour categories is a teaching model. Real systems have hybrids the model does not place: a rule the provider enforces but we do not, a validation failure discovered mid-transaction, a dependency that fails by returning a wrong answer rather than an error. The model earns its keep by making those visible as awkward cases rather than by having a slot for everything.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — deciding which failures are worth an alert, and how an error budget is spent, starts from exactly this categorisation and takes it further than a codebase can.