Cruxly › Low-level design › More Design Patterns
Chain of Responsibility
exactly one handler, chosen by trying candidates in order
Open the interactive lessonForcing problem: a request needs to be handled by exactly one of several possible handlers, the right one depends on runtime data, and hardcoding "try A, else try B, else try C" as a nested if-chain means every new handler type requires editing that same nested chain.
The violation: a hardcoded routing chain
class SupportTicketRouter {
void route(Ticket t) {
if (t.priority == Priority.CRITICAL) {
escalateToOnCall(t);
} else if (t.category == Category.BILLING) {
routeToBillingTeam(t);
} else if (t.category == Category.TECHNICAL && t.tier == Tier.ENTERPRISE) {
routeToEnterpriseSupport(t);
} else {
routeToGeneralQueue(t);
}
}
}
Adding a new routing rule means editing this method and reasoning about where the new else if needs to slot in relative to the existing ones — order matters, conditions can overlap, and the whole routing policy lives in one method that grows without bound as the business adds rules.
The fix: handlers that each decide independently, linked in sequence
abstract class TicketHandler {
protected TicketHandler next;
TicketHandler setNext(TicketHandler next) { this.next = next; return next; }
final void handle(Ticket t) {
if (canHandle(t)) {
process(t);
} else if (next != null) {
next.handle(t); // pass along; this handler had no opinion
} else {
throw new IllegalStateException("no handler matched: " + t);
}
}
protected abstract boolean canHandle(Ticket t);
protected abstract void process(Ticket t);
}
class CriticalPriorityHandler extends TicketHandler {
protected boolean canHandle(Ticket t) { return t.priority == Priority.CRITICAL; }
protected void process(Ticket t) { escalateToOnCall(t); }
}
class EnterpriseTechnicalHandler extends TicketHandler {
protected boolean canHandle(Ticket t) {
return t.category == Category.TECHNICAL && t.tier == Tier.ENTERPRISE;
}
protected void process(Ticket t) { routeToEnterpriseSupport(t); }
}
class BillingHandler extends TicketHandler {
protected boolean canHandle(Ticket t) { return t.category == Category.BILLING; }
protected void process(Ticket t) { routeToBillingTeam(t); }
}
class GeneralQueueHandler extends TicketHandler {
protected boolean canHandle(Ticket t) { return true; } // catch-all, always last
protected void process(Ticket t) { routeToGeneralQueue(t); }
}
// wiring, done once, at the composition root
TicketHandler chain = new CriticalPriorityHandler();
chain.setNext(new EnterpriseTechnicalHandler())
.setNext(new BillingHandler())
.setNext(new GeneralQueueHandler());
Adding a new rule is a new class plus one line of wiring — nothing existing is edited. This is Open/Closed applied to routing logic specifically: the same "new requirement means a new class" property covered elsewhere in this track, here achieved by letting each handler decide independently rather than centralising the decision in one method's if-chain.
The mechanism: passing along, not branching
The catch-all handler always goes last, and this is a wiring responsibility, not something the pattern enforces automatically. Nothing stops someone from wiring GeneralQueueHandler first by mistake, at which point it swallows every ticket immediately — the pattern removes the if-chain's editing cost but does not remove the need to reason about order where order genuinely matters (as it does here, since GeneralQueueHandler.canHandle always returns true).
Where this differs from Strategy, and when it is the wrong tool
Chain of Responsibility and Strategy are easy to confuse because both involve an interface with multiple implementations. The difference is who decides: with Strategy, the caller already knows which implementation it wants and selects it directly. With Chain of Responsibility, no single party knows in advance which handler is right — the right handler is discovered by asking each candidate in turn. If the caller already knows "this is a billing ticket, route it to Billing," that's Strategy; needing to discover which handler applies is Chain of Responsibility.
This is the same pattern behind most middleware pipelines — an HTTP request passing through authentication, logging, and rate-limiting middleware before reaching a route handler is a chain where, unlike the ticket example, every link processes the request (typically wrapping the call rather than exclusively choosing one), rather than exactly one handler claiming it exclusively. Naming that "middleware pipelines are Chain of Responsibility, but every link participates rather than exactly one" shows you understand the pattern has more than one shape.
When it's the wrong tool: if there is always exactly one correct handler and it is knowable in O(1) from the request's type — a Map<Type, Handler> lookup is simpler, faster, and does not require walking a list of candidates asking each one "is this yours?" Chain of Responsibility earns its place specifically when the matching logic itself is non-trivial or when handlers need to be reordered/added without touching a central dispatch table.
:::key A subtlety worth having ready: what happens if nothing matches The example above throws if the chain runs out without a match. The alternative — silently doing nothing — is almost always wrong, because a ticket that matches no handler and produces no error is a ticket that disappears without anyone noticing. Always terminate a chain with either a catch-all or an explicit failure, never silence. :::
How to say it in the interview
"The tell is a stack of if/else-if checking increasingly specific conditions to route a request to its handler — every new case means editing that same block, which is Open/Closed's smell again, just shaped as routing instead of behaviour."
Checklist
- Recognised a nested if/else routing chain as the same 'edit to extend' smell as Open/Closed
- Let each handler decide canHandle independently rather than centralising the decision
- Passed the request along unchanged when a handler declined it
- Placed the catch-all handler last as a deliberate wiring decision, not an automatic property
- Distinguished Chain of Responsibility (discovering the handler) from Strategy (caller already knows it)
- Named middleware pipelines as a variant where every link participates rather than exactly one
- Preferred a direct map lookup when the correct handler is knowable in O(1) from the request type
- Terminated an unmatched chain with an explicit failure rather than silently doing nothing