skip to content

A Chain of Responsibility explicitly allows a request to reach the end of the chain with nobody handling it. Why is that risky, and what are the options for handling fall-through?

level: seniorimportance: should knowfreq 40%

answer

  1. pattern guarantees nobody must handle it
  2. void + null successor = silent drop
  3. terminal default handler / Null Object
  4. Optional or sealed Handled|Unhandled forces caller
  5. dead-letter + unhandled counter + alert

basics

~20 s

Nothing guarantees a handler matches, so a request can be silently dropped — no result, no error, no log. Always define the end of the chain: a default handler, an explicit error, or a result type that forces the caller to deal with 'nobody handled it'.

solid answer

~50 s

The pattern's own definition says a request may traverse the entire chain and be received by no object. With a `void` handler signature, that becomes a silent no-op: no exception, no result, and often no log — the classic 'the message just vanished' incident. Options, roughly in order of safety: (1) a **terminal default handler** (Null Object) that always matches and defines the semantics — reject, route to a dead-letter queue, return 404, escalate to a human; (2) **throw** an explicit `UnhandledRequest` error so failure is loud, appropriate when non-handling is a bug; (3) return an **explicit result type** (`Optional`, `Either`, sealed `Handled|Unhandled`) so the compiler forces the caller to decide; (4) prove fall-through impossible via **exhaustive dispatch** over a sealed/enumerated request set, checked at build time. Whatever you choose, count and alert on unhandled requests: a fall-through rate that suddenly rises is a leading indicator of an unregistered or shadowed handler.

code

pseudocode · 9 lines
pseudocode
// 1) result type makes the gap visible to the compiler
fun dispatch(req): Result? = handlers.firstNotNullOfOrNull { it.handle(req) }

// 2) caller must decide - and the decision is instrumented
val result = dispatch(req) ?: run {
  metrics.increment("chain.unhandled", tag = req.kind)
  deadLetter.publish(req)              // durable, replayable
  return Response.unsupported(req.kind)
}

go deeper

for a junior

Say that if no handler matches, nothing happens by default, so the chain should end with a default handler or report an error.

for a middle

List the options — terminal default handler, throw, or an Optional/typed result — and pick one based on whether non-handling is a bug or an expected case.

for a senior

Add exhaustive dispatch for closed request sets, dead-letter queues with a replay path, and the metrics that distinguish 'nobody matched' from 'matched and rejected'.

for a principal

Frame fall-through as a data-loss risk class: mandate a durable default (DLQ) plus an unhandled-rate SLO and alert, define who owns the default policy for an extension point, and note that a chain requiring proven total coverage is a signal to move to a keyed dispatcher with build-time exhaustiveness.

## The hole in the contract Chain of Responsibility deliberately does **not** guarantee that any object handles the request. That property is what buys the decoupling — handlers need not know about each other or about total coverage. But it means "no one accepted it" is a legitimate, expected runtime state, and the design must say what it means. The danger is that the default implementation of that state is *nothing*: with `void handle(request)` and a `null` successor at the tail, the call returns normally, the sender continues, and no signal is produced anywhere. ## Why it bites in production - **New request kind ships before its handler** — a producer starts emitting a new event type; the consumer chain has no match; messages are acknowledged and discarded. Data loss with a green dashboard. - **Handler deregistration** — a refactor, feature flag, or failed bean/component registration removes a handler; the requests it owned start falling through. - **Shadowing** — an earlier broad handler consumes and rejects, or a guard is subtly narrowed (a changed threshold, a case-sensitivity bug), so the intended handler never matches. - **Config drift** — the chain is assembled per environment; production is missing a handler that staging has. In all of these the code is "working"; only the *policy at the end of the chain* can turn them into a visible failure. ## Option 1 — terminal default handler (Null Object / catch-all) Append a handler whose guard always matches. It gives the chain a total function and puts the policy in one named, testable place. Typical implementations: - reject with a domain error (`405 Method Not Allowed`, `UNSUPPORTED_TYPE`) - publish to a **dead-letter queue** for later replay — the right answer for messaging, because the payload is preserved - log at WARN/ERROR with the request's identifying fields plus a counter - escalate to a human queue (approval workflows) or apply a conservative default (deny) Benefits: the caller's code has no special case; behavior is uniform; the default is a normal object you can unit-test. Risk: a catch-all that merely logs at DEBUG is barely better than silence — the default must be *loud or durable*. ## Option 2 — throw on fall-through The driver throws `UnhandledRequestException` after the last handler declines. Right when non-handling means a programming error (an internal dispatcher over a closed set of types). Wrong for genuinely open-world input (arbitrary user messages, third-party webhooks), where it converts routine unknown input into error-rate noise and possibly a retry storm. ## Option 3 — explicit result type Make the chain's return type carry the possibility of no handler: `Optional<Result>`, `Either<Unhandled, Result>`, or a sealed `Handled(result) | Unhandled(request)`. The compiler then forces the caller to write the fall-through branch at the call site, where the surrounding context (is this a user-facing API? a background job?) is known. This is the most honest option in languages with good sum types, and it composes with option 1 (the caller may still delegate to a shared default). ## Option 4 — make fall-through impossible If the request space is closed — a sealed hierarchy, an enum of commands, a finite schema registry — you can guarantee coverage: - exhaustive `when`/`match` over the sealed type, checked by the compiler - a startup assertion that every enum case has a registered handler, failing fast at boot - a lookup map plus a build-time test that the key set equals the case set This converts a runtime risk into a build-time or boot-time error, at the cost of the open extensibility the chain gave you. Choose it when the set really is closed; do not fake closedness over open-world input. ## Operational guardrails (needed with any option) - **Counter** `chain.unhandled` labelled by request kind, with an alert on rate or on any occurrence for closed-world chains. - **Sample the payload** (redacted) so the unknown kind can be identified without a redeploy. - **Per-handler match counters** to distinguish "nobody matched" from "the wrong handler matched and rejected". - **Idempotent replay path** — if you dead-letter, you need a way to reprocess once the handler ships. Design the DLQ before you need it. ## Related trade-off The more you harden fall-through, the closer the chain gets to a dispatch table with exhaustiveness checks. That is a legitimate destination: if you find yourself asserting total coverage, the open-ended chain may no longer be the right shape, and a keyed dispatcher with an explicit default is simpler and faster.

  • When should the fall-through case throw instead of returning a default?
    Throw when non-handling can only mean a defect — an internal dispatcher over a closed set of commands, where a missing handler means a registration bug that should fail fast and loudly. Return a default (or a typed empty result) for open-world input such as third-party webhooks or user messages, where unknown kinds are expected; there, throwing turns normal traffic into error rate and can trigger pointless retries.
  • How would you detect that a handler was accidentally dropped from the chain in production?
    Two signals. Per-handler invocation/match counters: a handler whose match rate falls to zero after a deploy is either removed or shadowed. And an unhandled counter labelled by request kind, alerting on any nonzero rate for closed-world chains. Logging the composed chain at startup lets you confirm registration without attaching a debugger.

A memo circulated through every department where each may sign or pass it on. If the last desk has no out-tray rule, the memo simply ends up in a drawer — no rejection, no reply, and nobody notices until someone asks months later.

saying these in an interview costs you the question

  • Believing the pattern guarantees some handler will process every request.
  • Leaving the tail's successor null with a void signature, so non-handling is indistinguishable from success.
  • A catch-all handler that only logs at DEBUG — effectively still silent.
  • Dead-lettering without building a replay path, so preserved messages are never reprocessed.
  • Throwing on fall-through for open-world input, converting normal unknown traffic into error-rate noise and retry storms.

context