skip to content

When two registered exception handlers could both catch a thrown failure, how does a web framework choose which one runs?

level: middleimportance: must knowfreq 63%

answer

  1. two registry shapes, two rules
  2. nearest ancestor, not first declared
  3. scanned lists make order the rule
  4. broad registration shadows what follows
  5. wrapping changes the matched type

basics

~20 s

Most frameworks resolve by specificity: they walk the thrown type's ancestry and pick the nearest registered ancestor, so a subtype registration beats a base-type one. Where the registry is a scanned list of predicates, the first match wins and registration order decides.

solid answer

~50 s

It depends on how the registry is built. Where it is **keyed by type**, resolution walks the thrown object's type hierarchy outward and selects the nearest registered ancestor — an exact registration beats a subtype's parent, which beats the broadest base type. Where it is a **scanned list of predicates**, there is no hierarchy walk at all: the first entry that matches wins, so registration order is the rule and a broad entry placed early shadows everything after it. Frameworks that offer both usually let specificity decide and fall back to declared priority or registration order for genuinely ambiguous matches, such as two unrelated interfaces the failure implements. The practical habit that survives both models is: register narrow types first, give the broadest registration the lowest priority, and never assume an ambiguous pair resolves the way you hope.

go deeper

for a junior

Remember the default intuition: the most specific registration for the thrown type is the one that runs, and the broadest one is a last resort rather than the normal path.

for a middle

Be able to describe both registry shapes and their selection rules, walk a type hierarchy out loud to the nearest registered ancestor, and say why registration order matters in one shape and not the other.

for a senior

Show how you would prove which mapper ran — a test for an unregistered subtype, and a boundary log line pairing thrown type with resolved mapper — and how a wrapping layer silently changes the match.

for a principal

Treat resolution ambiguity as a design smell to legislate away: shallow, single-identity failure hierarchies and an explicitly lowest-priority catch-all, so status behaviour does not depend on a tie-break rule that varies by framework.

## Two different selection rules wear the same name "Which handler runs?" has two answers because registries come in two shapes, and a lot of confusing behaviour comes from assuming the wrong one. - A **type-keyed registry** stores mappers under failure types. Resolution is a hierarchy walk: take the thrown object's runtime type, look for a registration; if none, take its parent, and so on outward to the broadest type. The **nearest registered ancestor wins**, and registration order is irrelevant. - A **scanned list** stores (predicate, mapper) pairs and tests them in order. There is no notion of "nearer"; the **first predicate that matches wins**, and order is everything. Some frameworks expose a hybrid: a type key plus an explicit priority number used when two registrations are equally applicable. | | Type-keyed registry | Scanned predicate list | |---|---|---| | Selection rule | Nearest registered ancestor | First match in order | | Does declaration order matter? | Usually no | Always | | Effect of a broad registration | Fires only when nothing narrower exists | Shadows everything registered after it | | Typical tie-break | Declared priority | Position in the list | | Common surprise | Two unrelated interfaces match equally | Catch-all added "first, so it is set up early" | ## Walking the hierarchy Suppose a failure type for "order not found" extends a module-level "not found" type, which extends the broadest failure type in the language. Registrations exist for the module-level type and for the broadest type. The walk starts at "order not found": no registration. It moves to the module "not found": registration found, walk stops. The broadest registration never runs, even though it also *could* have caught the failure. That is the whole of the specificity rule, and it is why adding one narrow registration can silently change the status of a set of endpoints. Ambiguity appears when the walk cannot produce a single nearest match — most often because the failure implements two marker interfaces that both have registrations, at the same distance. Frameworks differ here: some pick the first declared, some require an explicit priority, some refuse and go to the default. **Do not design a system whose statuses depend on resolving that tie.** ## Wrapping moves the answer The registry matches the type of the object that actually arrives at the boundary. If an intermediate layer catches a domain failure and rethrows it inside a generic wrapper, the object at the boundary is the wrapper. Frameworks differ in whether they inspect the cause chain: some unwrap and retry the lookup against the cause, some match only the outermost type. Where unwrapping does not happen, the wrapper resolves to a broad registration and every carefully mapped domain status collapses into one. This is one of the most common reasons a mapping that "worked yesterday" stops working after a refactor that introduced a wrapping layer. ## Practical rules that hold in both models 1. **Register narrow first, broad last.** In a scanned list this is the correctness rule; in a type-keyed registry it costs nothing and it matches how readers expect the table to be read. 2. **Treat the broadest registration as a last resort** with the lowest priority, not as the default place to put shared behaviour. 3. **Keep the hierarchy shallow and intentional.** A deep tree of failure types makes "which ancestor is nearest" a puzzle at review time. 4. **Do not rely on interface-level ties.** Give a failure one registrable identity, not two equally close ones. 5. **Preserve the type at the boundary.** If a layer must wrap, either let the wrapper carry the mapping information itself or make sure the boundary unwraps before lookup. ## How to verify it rather than guess The cheap check is a test per registered type that asserts the status, plus one test for a subtype that is *not* registered — the second one is what catches an accidental shadowing. In operation, logging the resolved mapper's name alongside the thrown type turns "the wrong handler ran" from a guess into a single log line, and it is the fastest way to tell a specificity problem from an ordering problem. The reason interviewers ask this is that the answer separates people who have read a registry's configuration from people who have debugged one. Both rules are simple; the damage comes from applying the wrong one, usually by adding a broad entry to a scanned list and expecting the narrow ones to still win.

  • What happens when a failure implements two interfaces that both have registrations at the same distance?
    There is no nearest match, so the tie has to be broken elsewhere: frameworks variously use declared priority, registration order, or refuse the ambiguity and fall through to the default. Since the behaviour is not portable, avoid designing failure types that rely on winning such a tie.
  • How can adding one new registration change the status of endpoints nobody touched?
    Because lookup stops at the nearest registered ancestor. Registering a mapper on an intermediate type intercepts every subtype that previously walked past it to a broader registration, so endpoints raising those subtypes quietly start returning the new status.
  • Why does a refactor that wraps failures often break mapping?
    The registry matches the object that reaches the boundary. Once a layer wraps a domain failure, the outer type is the wrapper, and unless the framework unwraps the cause chain before lookup, every wrapped failure resolves to whatever broad registration matches the wrapper.

saying these in an interview costs you the question

  • Says the first registered handler always wins, regardless of registry shape
  • Registers a catch-all first in a scanned list and expects narrower ones to still fire
  • Assumes the framework always unwraps the cause chain before matching a type
  • Thinks a broad registration and a narrow one both run, in sequence
  • Believes an ambiguous two-interface match resolves the same way everywhere