skip to content

Handler order is load-bearing in a Chain of Responsibility. What failures does wrong ordering cause, and how do you make order explicit, testable, and safe to extend?

level: seniorimportance: must knowfreq 45%

answer

  1. shadowing: general before specific = dead handler
  2. prerequisite inversion: authz before authn
  3. one owner for order; phases > magic numbers
  4. disjoint guards make order irrelevant
  5. golden-order test + log chain at startup

basics

~20 s

Wrong order can let a broad handler swallow requests meant for a specific one, or let a step run before its prerequisite (e.g. authorization before authentication). Fix it by owning order in one explicit, tested place instead of leaving it implicit.

solid answer

~50 s

Two distinct hazards. **Shadowing**: in a first-match chain, a broad handler placed before a specific one consumes requests the specific handler was written for, leaving it dead and untested. **Prerequisite inversion**: a handler runs before the stage that establishes its precondition — authorization before authentication, business logic before rate limiting, caching before request normalization — producing wrong results or security holes. Both are invisible to the type system because the chain is just a list. Mitigations: keep ordering in exactly one place (an explicit builder, an ordered configuration list, or `@Order`-style priority metadata) rather than scattered `next` assignments; prefer coarse named phases (auth → validate → enrich → dispatch → fallback) so a new handler declares a phase, not a magic integer; make guards mutually exclusive where possible so order stops mattering; assert the resolved order in a test that fails when a new handler is registered; and emit the chain's composition at startup and the traversal path per request so production shows who actually handled it.

code

pseudocode · 11 lines
pseudocode
// order owned in ONE place, expressed as phases, and asserted in a test
val chain = ChainBuilder()
  .phase(TRACE)    { CorrelationId() }
  .phase(AUTHN)    { JwtAuthentication() }
  .phase(AUTHZ)    { RoleCheck() }        // may assume principal exists
  .phase(VALIDATE) { SchemaValidation() }
  .phase(DISPATCH) { InvoiceHandler(); GenericJsonHandler() } // specific first
  .phase(FALLBACK) { NotFoundHandler() }
  .build()

// test: assert(chain.names() == listOf("CorrelationId","JwtAuthentication", ...))

go deeper

for a junior

Know that a chain runs top to bottom and that a handler which matches everything must go last, or handlers after it never run.

for a middle

Name shadowing and prerequisite inversion with concrete examples, and put ordering in one explicit builder or config list rather than in scattered next-pointers.

for a senior

Add phase-based ordering, disjoint guards or map dispatch to remove the dependency, golden-order and end-to-end shadowing tests, and per-handler metrics that expose dead links.

for a principal

Treat the order as a published contract for an extension point: define phases and what each may assume, enforce with declared constraints plus startup topological sort and fail-fast, and require traversal-path telemetry so production can answer 'who handled this' without a debugger.

## Why order is a real dependency A Chain of Responsibility turns a conditional cascade into a list of objects. The `else if` sequence was at least visible in one function; the list's order is often produced by classpath scanning, DI registration, or a config file, and no compiler checks it. Order is therefore a **hidden coupling between handlers that never reference each other**. ## Failure mode 1 — shadowing (first-match chains) ``` handlers = [ AnyJsonRequestHandler, InvoiceJsonRequestHandler ] ``` The general handler matches every JSON request, consumes it, and the invoice handler never runs. Symptoms: the specific behavior "mysteriously stops working" after an unrelated deployment; unit tests for the specific handler pass (it is tested in isolation) while the system is wrong. This is the same rule as exception `catch` clause ordering, routing tables, and firewall rules: **most specific first**. Detection: coverage or a counter per handler — a handler that never fires in production is either dead or shadowed. ## Failure mode 2 — prerequisite inversion Some stages establish state that later stages assume: - authentication resolves the principal → authorization needs it - request normalization/decoding → validation and caching need canonical input - rate limiting / quota → must precede expensive work to be useful - tracing/correlation-id → must be first for downstream logs to be attributable - transaction or tenancy context → must wrap the handlers that touch storage Getting these backwards ranges from wasteful (rate limiting after the expensive call) to a security defect (authorization evaluated against an unresolved principal, or a cache keyed on un-normalized input serving one user's data to another). ## Failure mode 3 — ordering that changes results silently In broadcast/enrichment chains everyone runs, so nothing looks broken, but the composed outcome depends on order: two enrichers writing the same field, a sanitizer running after a formatter, a metric recorded before a retry. These are the hardest to catch because there is no missing behavior, only a wrong value. ## Making order explicit 1. **Single owner.** One factory/builder/config that lists the chain top to bottom, read like a script. Scattering `a.next = b` across constructors is the worst case — order is then a property of object graph assembly nobody reviews. 2. **Named phases over numeric priorities.** `@Order(150)` invites priority-number wars between teams; `phase = AUTHENTICATION` with a fixed, small phase sequence gives coarse guarantees and leaves within-phase order irrelevant by design. Reserve numeric ties for genuinely ordered handlers inside a phase. 3. **Declared constraints.** For large plugin sets, let a handler declare `runsAfter(Authentication)` / `runsBefore(Dispatch)` and topologically sort at startup, failing fast on cycles or unsatisfiable constraints. This turns implicit knowledge into a checkable graph. 4. **Disjoint guards.** If handlers' guards are mutually exclusive by construction (dispatch on an enum, on a URI prefix that cannot overlap, on a sealed request type), order stops affecting correctness. This is the strongest fix: prefer a **lookup map keyed by the discriminator** over a scan when the discriminator is exact — the chain earns its keep only when guards are genuinely overlapping or fuzzy. 5. **Exhaustive dispatch checks.** With a sealed/enumerated request hierarchy you can assert at build time that every case has a handler, removing the fall-through class of bugs entirely. ## Testing order - **Golden-order test**: assert the resolved handler sequence (list of class names) equals an expected list. It fails loudly when someone registers a handler, which is exactly the review trigger you want — the diff shows the new position. - **Shadowing test**: for each specific handler, send a request it should own through the *whole* chain (not the handler alone) and assert that handler responded — instrument via a spy, a returned handler id, or tracing. - **Prerequisite test**: send a request that would misbehave under inversion (unauthenticated request to a protected route; over-quota request that is expensive) and assert the early stage short-circuits. - **Property/fuzz check**: for a first-match chain, generate requests and assert at most one handler consumed, and that no handler is never selected across the corpus. ## Observability - Log the composed chain **once at startup** (ordered class names + phase) — this single line resolves most "why did it behave differently in staging" questions. - Per request, record the traversal path or at least the terminal handler id (a span/attribute, a response header in non-production, a structured log field). Without it, the pattern's runtime indirection becomes an operational blind spot. - Per-handler counters (invocations, matches) reveal dead and shadowed handlers. ## Extension policy When a chain is an extension point for other teams or plugins, order becomes a public contract. Publish it: the phase list, what each phase may assume, whether a handler may consume, and whether it may mutate the request. Otherwise every new plugin author guesses, and the guesses collide.

  • Your chain is populated by dependency injection from many modules. How do you stop order from being accidental?
    Do not rely on discovery order. Require each handler to declare a phase (and optionally runsAfter/runsBefore constraints), topologically sort at startup, and fail fast on cycles or missing constraints. Log the resolved order at boot and pin it with a golden-order test so any new registration shows up as a deliberate diff rather than a silent reshuffle.
  • When is a lookup map a better fit than an ordered chain?
    When the discriminator is exact and handlers are mutually exclusive — dispatch by message type, URL, or enum. A map gives O(1) resolution, no shadowing, no ordering dependency, and can be checked for exhaustiveness. Keep the chain when guards genuinely overlap, are predicate-based, or when several handlers must contribute to the same request.

Firewall rules: a permissive allow all line placed above your carefully written deny rule doesn't error — it just quietly makes the rest of the file decorative.

saying these in an interview costs you the question

  • "Order doesn't matter, each handler checks its own condition" — true only if guards are provably disjoint.
  • Wiring successors in constructors scattered across modules so nobody can read the resolved order.
  • Relying on classpath/DI discovery order, which can change between builds or environments.
  • Using numeric @Order priorities across teams, producing priority inflation wars.
  • Testing handlers only in isolation, which cannot detect shadowing or prerequisite inversion.

context