Error Hierarchy
How to design an error model that survives multi-agent development and client-side pattern matching.
Error Hierarchy
How to design an error model that survives multi-agent development and client-side pattern matching.
TL;DR (human)
One base class. One file of codes. Subclasses per namespace. Codes are append-only. Never throw raw Error at a boundary. The dispatcher is the only thing allowed to turn unknown thrown values into a generic opaque error.
For agents
Class shape
// packages/core/src/errors/app-error.ts
export type ErrorOpts = {
readonly hint?: string;
readonly docsUrl?: string;
readonly cause?: unknown;
};
export class AppError extends Error {
constructor(
readonly code: string,
message: string,
readonly opts: ErrorOpts = {},
) {
super(message, { cause: opts.cause });
this.name = this.constructor.name;
}
serialize() {
return {
code: this.code,
message: this.message,
hint: this.opts.hint,
docsUrl: this.opts.docsUrl,
};
}
}Subclasses
One subclass per namespace. They exist so callers can instanceof-check by namespace and so codes group together visually.
export class AuthError extends AppError {} // AUTH_REQUIRED, AUTH_FORBIDDEN, AUTH_EXPIRED
export class ValidationError extends AppError {} // VALIDATION_ERROR, VALIDATION_RANGE, ...
export class NotFoundError extends AppError {} // NOT_FOUND, NOT_FOUND_AFTER_DELETE
export class ConflictError extends AppError {} // CONFLICT_VERSION, CONFLICT_LOCKED
export class RateLimitError extends AppError {} // RATE_LIMIT_EXCEEDED, RATE_LIMIT_BLOCKED
export class BillingError extends AppError {} // BILLING_PAYLOAD_INVALID, BILLING_PROVIDER_DOWN
export class SecurityError extends AppError {} // SECURITY_EGRESS_DENIED, SECURITY_FIREWALL_BLOCKSubclasses do not add methods. They exist for type discrimination. Adding behavior makes them harder for agents to reason about.
Codes
One file. Append-only.
// packages/core/src/errors/codes.ts
export const ERROR_CODES = {
// auth
AUTH_REQUIRED: "AUTH_REQUIRED",
AUTH_FORBIDDEN: "AUTH_FORBIDDEN",
AUTH_EXPIRED: "AUTH_EXPIRED",
// validation
VALIDATION_ERROR: "VALIDATION_ERROR",
// existence
NOT_FOUND: "NOT_FOUND",
// conflict
CONFLICT_VERSION: "CONFLICT_VERSION",
// dispatcher synthetics
METHOD_NOT_FOUND: "METHOD_NOT_FOUND",
HANDLER_NOT_BOUND: "HANDLER_NOT_BOUND",
HANDLER_THREW: "HANDLER_THREW",
// ...
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];Rules:
- Format:
\<NAMESPACE\>_\<REASON\>, all caps, snake-case, ASCII. - Append-only. Never rename; deprecate and add a new code.
- One source file. If you need categorization, use comments and grouping. Do not split across files.
- Every new code needs a one-line entry in
docs/errors/\<CODE\>.mdwith: cause, hint, recovery, link to relevant ADR if any.
When to throw what
| Situation | Class | Code |
|---|---|---|
| Schema parse failed | ValidationError | VALIDATION_ERROR |
| Unauthenticated caller hit auth-required method | AuthError | AUTH_REQUIRED |
| Authenticated caller lacks capability | AuthError | AUTH_FORBIDDEN |
| Resource id not in storage | NotFoundError | NOT_FOUND |
| Optimistic-lock version mismatch | ConflictError | CONFLICT_VERSION |
| Egress to non-allowlisted domain | SecurityError | SECURITY_EGRESS_DENIED |
| Method exists but handler not registered | AppError (dispatcher) | HANDLER_NOT_BOUND |
| Handler threw a non-AppError | AppError (dispatcher) | HANDLER_THREW |
Lint rules
Ban throw new Error( in boundary files:
// .eslintrc.cjs
{
files: [
"packages/*/src/methods/**",
"packages/*/src/handlers/**",
"packages/*/src/api/**",
],
rules: {
"no-restricted-syntax": ["error", {
selector: "ThrowStatement > NewExpression[callee.name='Error']",
message: "Throw a typed AppError subclass with a stable code instead.",
}],
},
}Escape hatch: // allow-raw-error: \<reason\> on the line above; a gate counts these.
Wire serialization rules
- The wire payload contains
code+message+ optionalhint+ optionaldocsUrl+requestId. - Never serialize
cause. It can contain stack traces, file paths, env values, or secrets. - Log the
causeserver-side, tagged with therequestId, so support / on-call can correlate. - Intl-resolve the
messageat the boundary if the caller is a UI surface; do not assume the client speaks English.
Tests
Each method's contract test (per contracts-zod-pattern.md) covers:
- Happy path: valid params, valid result.
- Reject path: invalid params produce a
ValidationErrorwith codeVALIDATION_ERROR. - Auth path: missing
principalIdproducesAUTH_REQUIRED.
Plus, every error code is exercised somewhere in the test suite — a separate gate scans tests for code: "\<CODE\>" assertions and fails if any code in ERROR_CODES is never asserted.
Common failure modes (sourced from production)
- Agent throws
new Error("not authorized"). Client cannot pattern-match. → Lint blocks rawErrorin boundary files. - Agent renames a code from
AUTH_FORBIDDENtoFORBIDDEN. Existing clients stop matching. → Codes are append-only; renames require an RFC + a deprecation cycle. - Codes drift in naming convention. Some
AUTH_REQUIRED, someAuthRequired. → One source file + a gate that asserts shape. - Stack trace in
error.dataover the wire. Leaks/Users/\<dev\>/.envand the cwd. → Stripcauseat serialization; log it server-side instead. - Error message changes break a client assertion. Tests assert on
.messageinstead of.code. → Tests assert on.code; messages are intl-resolved and may change.
See also
contracts-zod-pattern.md— the dispatcher serializes these.../security/README.md— audit-ledger entries reference these codes.../../templates/ADR.template.md— error-namespace renames go through an ADR.
Distributed Data Pattern
How to design data layout when one database stops being enough — read replicas, sharding, replication lag, CAP trade-offs, eventual consistency.
Event Streaming Pattern
How to design async, decoupled communication via queues, pub/sub, and event streams — without losing events, double-processing, or stalling consumers.