An Error Taxonomy That Survives Contact
Six kinds — InvalidInput, NotFound, Conflict, Unauthorized, DependencyTimeout, InternalBug — chosen because each one gets a different response. The taxonomy is a type-level decision, not a status-code table.
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.
How many error kinds should my codebase have, and what makes one kind genuinely different from another?
Three teams have each invented their own error types. Reservations throws BookingError, billing returns {ok: false, msg} and search throws raw driver exceptions. Nobody can write a shared retry policy, a shared alert rule, or a shared support tool.
Mirror HTTP. Make an error class per status code — BadRequestError, NotFoundError, ConflictError, ForbiddenError, GatewayTimeoutError — and throw those from the domain. The mapping to the wire is then free.
The domain now depends on HTTP. The first background job that reuses the booking rules has to catch a BadRequestError in a process where nothing is a request, and the name lies at every call site (Dependency Direction).
- The domain now depends on HTTP. The first background job that reuses the booking rules has to catch a
BadRequestErrorin a process where nothing is a request, and the name lies at every call site (Dependency Direction). - Status codes and response decisions are not the same partition.
409 Conflictand422 Unprocessableare different codes and identical responses on our side;504from our dependency and504we return mean opposite things. Sorting by the wire sorts by the wrong key. - The next consumer is gRPC, or a queue, or an internal SDK, and the taxonomy has to be re-derived because it encoded one transport's vocabulary (Leaky Abstractions).
- Someone adds
TooManyRequestsErrorbecause a new endpoint needed it, and now there are nine kinds, then fourteen, and the shared retry policy has a default branch that nobody has audited.
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.
- All three modules ship in the same deployable and must keep working during the unification — there is no big-bang release (Incremental Migration).
- Two of the three are called from a public HTTP API whose status codes are already documented and cannot move (Backward Compatibility as a Constraint).
- The team is eight people across three squads, so the taxonomy only works if all eight can classify a new failure the same way without a meeting.
- Every failure that leaves a module carries a kind from the closed set — there is no "other".
- The kind determines the response: retryability, log level, and whether a human is woken up. Nothing downstream may decide those from the message text.
- An
InternalBugis never manufactured deliberately. It exists as a category so that unclassified failures are visible, not so that code can label things with it.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The taxonomy itself is owned by one module, depends on nothing, and is small enough to read in ten seconds.
- Each domain module owns classifying its own failures — it knows whether a double-booking is a
Conflictand nobody else does. - The HTTP edge owns the kind-to-status mapping and is the only place status codes appear (Error Boundaries).
- The adapter around each external dependency owns turning that vendor's failures into
DependencyTimeoutor a domain kind, and is the only place the vendor's vocabulary exists.
- The taxonomy sits below every module and above nothing: it is a leaf dependency, which is what makes it safe for all three squads to share (Stable Dependencies).
- The line between
InvalidInputandConflictis the line between "this request could never have worked" and "this request would have worked a second ago". That is the boundary that decides retryability, which is why it is worth arguing about. - The line between
UnauthorizedandNotFoundis a security boundary, not a naming one — leaking existence to an unauthorised caller is a real disclosure (Trust Boundaries).
Six kinds, and what makes each one distinct
The justification for a kind is never "this situation is different". Every situation is different. The justification is that the system should *do* something different, and the table below is really a table of responses with the failures grouped under them.
Read the last column as the design constraint: if you cannot say what a proposed seventh kind does differently, it is evidence, not a kind.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| InvalidInput | A field is missing, malformed or out of range | The caller sent something that could never have been accepted | Reject at the edge with the field path; never retry; do not log as an error (Parse, Do Not Validate is the backend framing). |
| NotFound | The referenced thing does not exist | A stale id, a deleted resource, or a caller guessing | Return it as an outcome; do not retry; consider whether it should be indistinguishable from Unauthorized. |
| Conflict | The operation would violate a rule given current state | A concurrent change, a duplicate submit, or a state-machine guard refusing (Invalid Transitions) | Return the current state so the caller can decide; retry only after re-reading, never blindly. |
| Unauthorized | The caller may not do this | Missing, expired or insufficient authority | Deny without disclosing what exists; audit it; never retry (Least Privilege as a Design Decision). |
| DependencyTimeout | Something outside us was slow or unreachable | The network, a provider, a database under load | Retry with backoff if the operation is idempotent; degrade if there is a fallback; report as ours, not theirs (Retries Are a Property of the Operation). |
| InternalBug | An impossible state, a null, an unhandled case | Our defect | Do not handle. Propagate, capture context, alert, fix. Retrying reproduces it exactly. |
The taxonomy is a type, and the wire is a table
The whole point of the separation is that these two things change for different reasons. The kinds change when the system's idea of "what can go wrong" changes, which is rare. The mapping changes when a consumer needs a different status code, which is not.
The evidence payloads are what stops the taxonomy from being a downgrade. Six kinds carry less information than a free-text message — unless each kind carries the specific facts that made it that kind.
1export type Failure =2 | { kind: 'InvalidInput'; field: string; expected: string }3 | { kind: 'NotFound'; resource: string; id: string }4 | { kind: 'Conflict'; rule: string; currentState: string }5 | { kind: 'Unauthorized'; needed: string }6 | { kind: 'DependencyTimeout'; dependency: string; waitedMs: number }7 | { kind: 'InternalBug'; requestId: string }8 9// the ONLY place status codes exist10const STATUS: Record<Failure['kind'], number> = {11 InvalidInput: 422, NotFound: 404, Conflict: 409,12 Unauthorized: 403, DependencyTimeout: 503, InternalBug: 500,13}14 15export const retryable = (f: Failure) => f.kind === 'DependencyTimeout'The Record<Failure['kind'], number> is doing the work: a seventh kind is a compile error here and in every other exhaustive site, so the cost of growing the taxonomy is paid at the moment of the decision rather than discovered in production.
Where teams genuinely disagree: NotFound or Unauthorized
This is the one row of the table that is a real argument rather than a preference, and it is worth working through because it shows that a taxonomy encodes policy, not just structure.
Whichever you choose, choose it once and centrally. The failure mode is a codebase where half the endpoints leak existence and half do not, because then the inconsistency itself is the disclosure — an attacker learns which endpoints are hardened, and the ones that are not are advertised.
Do we answer NotFound or Unauthorized when the resource exists but the caller has no right to it?
when Ids are guessable or enumerable, the resource's existence is itself sensitive, or you are a multi-tenant system where one tenant must not learn another's ids exist
cost Support and client developers get a misleading answer, and "it says not found but I can see it in the admin panel" becomes a recurring ticket. Debugging costs go up permanently in exchange for a disclosure you close once.
when Ids are already known to the caller, the system is internal, or the distinction genuinely helps a legitimate user fix their own problem
cost The response confirms existence to anyone who can guess an id, which is a real enumeration channel in any system where ids are sequential or emailed around (Trust Boundaries).
when You need both — the honest answer in logs and audit, the safe answer on the wire
cost Two representations of the same failure, which means the mapping has a branch that depends on the audience. That branch is exactly the sort of thing that gets copied wrong into the second edge you build.
How to build it
Most important first.
- Choose kinds by response, not by cause. Two failures are the same kind when the retry policy, the log level and the user-facing message shape all agree; otherwise they are different kinds.
- Six is a working number:
InvalidInput,NotFound,Conflict,Unauthorized,DependencyTimeout,InternalBug. Each has a distinct answer to "retry?" and "whose problem?", which is the test a seventh has to pass. - Attach the evidence to the kind:
InvalidInputcarries a field path,Conflictcarries what conflicted,DependencyTimeoutcarries which dependency and the elapsed time (Stable Identifiers). - Keep it a closed discriminated union so the compiler enumerates cases at every mapping point, and so adding a kind is a decision with visible consequences rather than a quiet addition.
- Map to the wire once, in a table the edge owns. The table is allowed to be many-to-one; the taxonomy is not allowed to grow to make it one-to-one (Error Boundaries).
- Migrate module by module behind the shared type, leaving the old classes as deprecated aliases until the last call site moves (Expand and Contract).
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.
- Adding a *kind* is deliberately expensive: the compiler flags every mapping table and every policy switch, which is a handful of files and a conversation. That is the correct price — a seventh kind changes how the whole system responds.
- Adding a new *failure* within an existing kind is free: classify it, attach evidence, ship. This is the common case and it is now a one-line change.
- Changing a mapping — deciding that
Conflictshould be 422 rather than 409 — is one row in one table, and the domain does not move at all. - What stays expensive: reclassifying an existing failure after it has been in logs for a year. Dashboards, alert rules and support runbooks all key off the old kind, so the change is a data migration in everything but name (Data Migration).
- A closed set forces classification decisions that a free-text message let you postpone. Sometimes the honest answer is "we do not know yet", and the taxonomy has no comfortable place for it except
InternalBug. - Six kinds is coarser than reality. Support will ask "why did it fail" and get an answer one level less specific than the message used to give, unless the evidence payloads are taken seriously.
- Sharing one taxonomy across three squads couples their release decisions in exactly one place. That is a small, deliberate coupling — and it is still coupling (Shared Libraries).
What can go wrong
InternalBugbecomes a dumping ground: anything nobody wants to classify goes there, the bug alert becomes noisy, and the category loses the one property that made it useful.- Two squads classify the same situation differently — one calls a stale-version write
Conflict, the otherInvalidInput— and the retry policy behaves inconsistently for reasons that are invisible in the code. - The kind is added but the message keeps carrying the real information, so downstream code still parses strings and the taxonomy is decoration.
- The mitigation fails on its own terms: a shared taxonomy owned by no squad gets a new kind added by whoever needs it most urgently, and within a year it is the union of three private taxonomies rather than a design.
- Every module depends on the taxonomy; the taxonomy depends on nothing, not even the standard error type in languages where that is avoidable.
- The HTTP edge depends on the taxonomy plus a mapping table. The taxonomy has no idea HTTP exists — that is the property being bought.
- Support tooling and alert rules depend on the
kindstring surviving into logs and storage, which quietly makes it a serialized contract (API Stability).
- "This is the same as HTTP status codes." It is not, and the id collision is deliberate: the API Design lesson of the same name is about the contract on the wire, and the Backend Engineering one is about the request path. This one is about which kinds exist as types inside the codebase — the same six kinds map to a dozen status codes, and a job with no HTTP still needs them.
- "More kinds means more precision." Beyond the point where two kinds get the same response, extra kinds add classification work and no behaviour. Precision belongs in the evidence payload, not in the number of branches (Over-Decomposition).
- "
InternalBugis a kind like the others." It is a category for things that escaped classification. Code should almost never construct one deliberately; when it does, that is usually an assertion that should have been a type (Making Illegal States Unrepresentable). - "We can add the taxonomy later." The classification decision is cheap while the failure is being raised and expensive afterwards, because the evidence needed to classify it was in scope at the throw site and nowhere else.
- primitive-obsession
- duplicate-knowledge
Testing it, and how it ages
- A test per module that every public entry point returns only kinds from the closed set — the escape hatch is what you are actually testing for.
- Table tests on the kind-to-status mapping at the edge, with no domain code involved (What a Unit Is).
- A test that an unauthorised read of an existing resource is indistinguishable from a read of a missing one, if that is the policy you chose.
- A contract test with each consumer that the
kindstring in the serialized payload has not silently changed (Contract Tests).
- The six kinds are stable for a long time; the evidence payloads attached to them churn constantly, which is the right thing to be churning.
- The first real pressure comes from rate limiting and quota exhaustion, which is genuinely neither
InvalidInputnorDependencyTimeout— that is when to consider a seventh, and it should be a decision record (Architecture Decision Records). - It stops being right when one deployable becomes several services with separate release cycles, because a shared closed union across a network boundary becomes a versioning problem rather than a compiler check (Versioned Interfaces).
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.
- GENERALGrouping failures by the response they need is transport-agnostic and paradigm-agnostic: the same six kinds are useful in a CLI, a queue consumer and an HTTP service, because all three must decide retryability and severity.
- LANGUAGE-SPECIFICA closed union with exhaustiveness checking makes "did we handle every kind" a compile error in TypeScript, Rust or Kotlin. In Python or Ruby the same taxonomy needs a test asserting the mapping table is total, so the discipline is identical and the enforcement is weaker and later.
- SCALE-SPECIFICInside one deployable a shared closed union is a compiler check. Across independently released services it becomes a distributed enum-versioning problem where an old consumer meets a new kind, and the right answer shifts toward an open string with a documented default (Semantic Versioning).
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the kind is what an alert rule and an error budget are computed over, so the taxonomy chosen here decides what "the error rate" even measures.