ErrorsGENERALDOMAIN-SPECIFICSIMPLIFIED

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.

The question

Which failures belong in my domain model, which are translation, and which are simply bugs I must never handle?

The requirement

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

The obvious build

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.

Why it breaks

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.

How it breaks as requirements change
  • 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 TypeError in 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.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • 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.
Invariants
  • 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.

Responsibilities
  • 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).
Boundaries
  • 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).
CategoryExampleWhose faultRetry helps?Correct responseMust never
Expected business failureCard declined; insufficient balance; coupon expiredNobody's — the domain said noNoReturn it as a value; record it; tell the user what to do nextBe logged as an error or counted in the error rate
Validation failurePostcode malformed; quantity is -3The caller'sNot without a changeReject at the edge, naming the field, before any domain object existsBe discovered halfway through a write
Dependency failureProvider timed out; connection reset; 503The outside world'sOften, if the operation is idempotentRetry with backoff, degrade, or fail honestly with a request idBe reported to the user as their mistake
Programming bugUndefined is not a function; impossible state reachedOursNo — it will fail identicallyPropagate, capture context, alert, fixBe 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.

Charging a card
Everything is an Error
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.
The outcome is the return type
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"

SIMULATEDThe module and test lists are an Engineer Atlas model of a mid-sized payments service, not a measurement of a real one. The shape transfers — a category decision reconstructed from message text touches everything downstream of the throw — while the specific counts are illustrative.

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.

Retry only what is safe to retry
The change

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.

Every failure is an `Error` with a message
ChargeServiceStripeClientRetryHelperErrorMiddlewareAdminRetryToolReconciliationJob
testscharge_testretry_helper_testmiddleware_testreconciliation_test
6 modules · 4 test files

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

A closed union of four categories, mapped once in the adapter
RetryPolicyStripeAdapter
testsretry_policy_teststripe_adapter_mapping_test
2 modules · 2 test files

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.

what it cost The union is now a published contract inside the codebase: six modules pattern-match on it, so adding '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 kind field 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 PaymentError is 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.

Cost of the next change
  • 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 switch in 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).
What the recommended approach costs
  • Every call site now has to say what it does with each category. That is more code than try/catch and 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

Failure modes
  • 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 catch written 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.
Dependencies, and their direction
  • 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.
Misreads
  • "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.
Smells this explains
  • primitive-obsession
  • swallowed-errors

Testing it, and how it ages

What to test, and at which boundary
  • 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 TypeError is *not* caught by the dependency-failure handler. This is the assertion teams skip and the one that keeps bugs visible.
How this design ages
  • 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.

Domains that do not exist yet
  • 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.