Category: Behavioral
The problem
A request might need to be handled by one of several possible handlers, but the sender
shouldn't have to know which one, or hard-code the decision logic for picking it. A single
if/else if chain checking every handler's eligibility condition works at first, but it puts
every handler's business rule in one place, coupled to every other handler's rule, and adding
a new handler means editing that shared method.
The solution
Chain the handlers together, each holding a reference to the next. Each handler decides for itself whether it can (or should) handle the request; if not, it passes the request along. The sender only ever talks to the first link — it doesn't know how long the chain is, or which link actually processes the request.
classDiagram
class Handler {
-next
+handle(request)
}
class ConcreteHandlerA
class ConcreteHandlerB
class ConcreteHandlerC
Handler <|-- ConcreteHandlerA
Handler <|-- ConcreteHandlerB
Handler <|-- ConcreteHandlerC
ConcreteHandlerA --> ConcreteHandlerB : next
ConcreteHandlerB --> ConcreteHandlerC : next
Classic example
classic/Approver
is the canonical purchase-approval chain: Supervisor →
Manager →
Director,
each with its own approval ceiling. A request for an amount within the Supervisor's limit never
reaches the Manager at all; a request beyond every link's limit falls off the end of the chain
with a clear "no approver available" result rather than an exception or a silent no-op.
ApproverTest
covers an amount stopping at each of the three tiers, plus the beyond-everyone case.
Applied example: transaction compliance pipeline
applied/ComplianceHandler
chains KycHandler →
AmlHandler →
LimitHandler →
FraudHandler
— identity verification before watchlist screening before the business limit check before the
(more expensive) fraud heuristic, mirroring how a real compliance pipeline is actually ordered:
cheapest, most-decisive checks first. The first handler to reject a transaction stops the chain
immediately; later handlers never even see it, which is exactly what keeps, say, the fraud
heuristic from running on a transaction that was never going to pass KYC anyway.
ComplianceHandlerTest
covers a transaction clearing every check, and each individual handler being the one to reject.
When not to use it
- If every handler always needs to run regardless of what earlier ones decided (not "first match wins"), this isn't Chain of Responsibility — that's just a plain sequence of steps, or Decorator if each step wraps and enriches a result rather than short-circuiting it.
- A chain that's grown very long, or whose link order matters in ways that aren't obvious from reading any single link, becomes hard to debug — "why did this request get rejected" requires mentally replaying the whole chain. Keep chain order intentional and documented, like the KYC-before-AML-before-limits-before-fraud ordering here.
- If exactly one handler should always run based on a value that's known up front (not "whichever one happens to accept first"), a direct lookup (see this repo's Strategy or Factory Method modules) is more explicit than a chain.
Test coverage
100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:
./gradlew :behavioral:chainofresponsibility:jacocoTestReport
Report at behavioral/chainofresponsibility/build/reports/jacoco/test/html/index.html.
Further reading
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — Chapter 5 formalizes Chain of Responsibility; the book's own example (a context-sensitive help system escalating through UI widgets) is a direct ancestor of both examples here.
- Parnas, D. L. (1972). "On the Criteria to Be Used in Decomposing Systems into Modules." Communications of the ACM, 15(12), 1053–1058. — the same information-hiding argument cited in this repo's Strategy module applies here too: each handler hides its own eligibility rule from every other handler and from the sender, which is exactly what lets a new handler be added without touching existing ones.
Unit tests
src/test/java/com/designpatterns/behavioral/chainofresponsibility/classic/ApproverTest.java
package com.designpatterns.behavioral.chainofresponsibility.classic;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ApproverTest {
private final Approver chain = new Supervisor().next(new Manager().next(new Director()));
@Test
void aSmallAmountStopsAtTheSupervisor() {
assertThat(chain.approve(500_00L)).isEqualTo("Supervisor approved 50000 cents");
}
@Test
void aMidSizedAmountEscalatesPastTheSupervisorToTheManager() {
assertThat(chain.approve(5_000_00L)).isEqualTo("Manager approved 500000 cents");
}
@Test
void aLargeAmountEscalatesAllTheWayToTheDirector() {
assertThat(chain.approve(50_000_00L)).isEqualTo("Director approved 5000000 cents");
}
@Test
void anAmountBeyondEveryLinksLimitFallsOffTheEndOfTheChain() {
assertThat(chain.approve(1_000_000_00L)).isEqualTo("No approver available for 100000000 cents");
}
}
src/test/java/com/designpatterns/behavioral/chainofresponsibility/applied/ComplianceHandlerTest.java
package com.designpatterns.behavioral.chainofresponsibility.applied;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ComplianceHandlerTest {
private static final long LIMIT_CENTS = 50_000_00L;
private final ComplianceHandler chain =
new KycHandler().next(new AmlHandler().next(new LimitHandler(LIMIT_CENTS).next(new FraudHandler())));
@Test
void approvesATransactionThatClearsEveryCheck() {
ComplianceTransaction transaction = new ComplianceTransaction("payer-1", 10_000_00L, true, false, false);
ComplianceResult result = chain.check(transaction);
assertThat(result.approved()).isTrue();
assertThat(result.reason()).isNull();
}
@Test
void anUnverifiedPayerIsRejectedByKycBeforeAnyLaterCheckRuns() {
ComplianceTransaction transaction = new ComplianceTransaction("payer-2", 10_000_00L, false, true, true);
ComplianceResult result = chain.check(transaction);
assertThat(result.approved()).isFalse();
assertThat(result.reason()).isEqualTo("KYC: payer not verified");
}
@Test
void aWatchlistedPayerIsRejectedByAml() {
ComplianceTransaction transaction = new ComplianceTransaction("payer-3", 10_000_00L, true, true, false);
ComplianceResult result = chain.check(transaction);
assertThat(result.reason()).isEqualTo("AML: payer is on a watchlist");
}
@Test
void anAmountAboveTheThresholdIsRejectedByTheLimitHandler() {
ComplianceTransaction transaction = new ComplianceTransaction("payer-4", 60_000_00L, true, false, false);
ComplianceResult result = chain.check(transaction);
assertThat(result.reason()).isEqualTo("LIMIT: amount exceeds the 5000000 cent threshold");
}
@Test
void aHighRiskFlaggedTransactionThatClearsEverythingElseIsRejectedByFraud() {
ComplianceTransaction transaction = new ComplianceTransaction("payer-5", 10_000_00L, true, false, true);
ComplianceResult result = chain.check(transaction);
assertThat(result.reason()).isEqualTo("FRAUD: transaction flagged as high risk");
}
}