defineErrorMiddleware matched by instanceof only; the custom server's tsx-bound DI graph and the Next-compiled route handler each load their own copy of every error class, so mapped domain errors (e.g. UnauthenticatedError -> UNAUTHORIZED) fell through and surfaced as raw 500s — hit in production by a downstream app built from this template. Match on the canonical error name (probed from the constructor's string-literal this.name) with instanceof as the fast path; nameless classes stay identity-only so plain Errors never translate. ADR-027 records the decision and the now-load-bearing this.name convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4.7 KiB
ADR-027 — Domain errors match by canonical name, not class identity
Status: Accepted · 2026-07-31
Context
defineErrorMiddleware (core-shared) translates feature domain errors to
tRPC codes from feature-owned [Ctor, "CODE"] maps. It originally matched
result.error.cause instanceof Ctor — class identity.
A downstream app built from this template hit the failure in production:
a use case threw UnauthenticatedError, whose [UnauthenticatedError, "UNAUTHORIZED"] mapping exists, yet the client received a raw 500. Root
cause: web-next starts through the template's custom server (node --import tsx server.ts — the Socket.IO-ready entry). The DI container binds feature
modules loaded by tsx, while the tRPC route handler executes inside the
Next/webpack-compiled bundle. Two module graphs, two copies of every
error class. The thrown instance and the registered constructor are
different objects, instanceof is false, and every mapped error falls
through to tRPC's default INTERNAL_SERVER_ERROR.
The failure is doubly deceptive: the HTTP status lies (500 for an expected
domain condition), and a routine domain signal presents as a server fault —
in the downstream incident an expected UNAUTHORIZED was debugged as an
infrastructure crash.
Constraints on any fix:
- Feature-owned maps stay. core-shared must not enumerate feature error
classes (existing rule), and the
[Ctor, "CODE"]call-site API is used across features/cores — churn there multiplies the diff. - Server minification. Next minifies server bundles; class identifiers
(
Ctor.name) may be mangled, so a fix keyed on the static class name is not reliable. String literals survive minification. - Existing convention. Every domain error class in the template already
sets
this.name = "<ClassName>"from a string literal in its constructor.
Decision
- Match by canonical error name, with identity as a fast path. At
middleware-definition time, probe one instance per registered constructor
(
new Ctor("__probe__")) and record its.name— the value assigned from the string literal, which survives both module-graph duplication and minification. At request time an error matches an entry whencause instanceof Ctororcause.name === canonicalName. - Nameless classes match by identity only. A class that never sets a
custom name probes as the inherited
"Error"; name-matching is disabled for that entry, otherwise every unmapped plainErrorwould translate. this.namefrom a string literal becomes a load-bearing convention. Every domain error class MUST assignthis.name = "<ClassName>"in its constructor (string literal, matching the class name), and error constructors MUST be side-effect-free and callable with a single message argument (the probe relies on it). This was already universal practice; it is now the matching key.- Call sites unchanged. All existing
defineErrorMiddleware([[Ctor, "CODE"], ...])usages keep working verbatim.
Alternatives considered
Ctor.namecomparison (no probe) — rejected: minified server bundles can mangle class identifiers while leaving the constructor-body string literal intact; the probe reads the literal.- Shared
DomainErrorbase class with acodediscriminator — rejected (for now): touches every feature's error classes and public contracts to achieve the same runtime behavior name-matching gets with a one-file change. Revisit if error metadata beyond a code is ever needed. - Unify the module graphs (make the webpack bundle resolve the same module instances the tsx DI graph holds) — rejected: depends on Next bundling internals and externals configuration, fragile across upgrades, and would silently regress the day either toolchain changes resolution.
- Serialize errors at the feature boundary (throw plain objects with a
code) — rejected: reshapes the whole error architecture (capture,
__sentryReportedflag,.causechains) for a problem the name check solves locally.
Consequences
- Domain errors now surface with their mapped codes regardless of which module graph constructed them; 500 again means "actual server fault".
- Within one feature's map, two classes sharing a name would both match the first entry. Maps are feature-scoped and small, and names conventionally equal the class name, so collisions indicate a naming bug — acceptable.
- The regression test encodes the failure mode (structurally identical class, same name, different identity) so the cross-graph scenario stays covered even where local test runs use a single module graph.
- Any generator or new feature emitting error classes must keep the
this.namestring-literal assignment — it is no longer cosmetic.