Chain of Responsibility Design Pattern in Java: A Complete Guide with Real-World Examples
Learn the Chain of Responsibility design pattern in Java with simple explanations and practical examples. This guide covers how a request passes through multiple handlers, with before-and-after code for beginners and experts.
When you start working on a Java project, writing a few if-else conditions to handle business logic feels perfectly fine. The problem usually starts when the requirements keep growing. I have seen this kind of problem in applications where different business rules gradually get mixed into the same method. One practical way to organize such code is the Chain of Responsibility design pattern.
Why the Chain of Responsibility Pattern Still Matters in Java
Think about a payment application. Before processing a transaction, you may need to check whether the request is valid, whether the account is active, whether the transaction amount is within the allowed limit, and whether the payment looks suspicious.
Initially, you might put all these checks inside one service method. A few months later, that method may contain so many conditions that even a small change becomes difficult to manage. The Chain of Responsibility pattern solves this by giving each check its own class.
A request enters the chain, and each handler decides whether to process it, forward it, or stop the chain when the request cannot continue. Whether you're building payment pipelines, authentication flows, approval workflows, or request-processing middleware, Chain of Responsibility keeps your code flexible and your responsibilities separated.
What Is the Chain of Responsibility Design Pattern?
"Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it."
The Chain of Responsibility is a behavioral design pattern in which a request passes through a sequence of handlers. Each handler has a particular responsibility. It can process the request, pass it to the next handler, or stop the chain when the request cannot continue.
Let's take a simple example. Suppose you raise a technical issue with your company's support team. The first person checks whether they can resolve it. If they cannot, the issue moves to the next person. The process continues until someone handles it or the team determines that no available handler can resolve it.
Java applications can follow the same approach. Instead of putting every business rule into one large method, we create separate classes for individual tasks and connect them in the required order.
The Three Players
| Role | Responsibility | Example |
|---|---|---|
| Handler | Defines the common operation for processing and forwarding | PaymentHandler |
| Concrete Handler | Implements one specific check and decides to continue or reject | TransactionLimitHandler |
| Client | Builds the chain and submits the request to the first handler | PaymentApplication |
Chain of Responsibility example: each payment-validation handler checks the request before allowing it to continue.
How to read this diagram (for freshers): The blue path shows the happy flow — a Payment Request enters the chain and passes through Request Validation, Account Verification, Transaction Limit Check, and Risk Check. If every check passes, the green path leads to Payment Processing. If any mandatory check fails, the red path shows the request being rejected immediately — it never reaches the remaining handlers.
Understanding the Pattern Through a Payment Example
Before we touch code, let's look at the Chain of Responsibility pattern you already use every day.
Example 1: A Payment Gateway
Suppose you are developing a payment service using Java and Spring Boot. Before accepting a payment, the application needs to perform four checks:
- Check whether the payment request contains valid data.
- Verify that the customer's account is active.
- Check whether the transaction amount exceeds the permitted limit.
- Perform a basic risk check before allowing the payment to continue.
Each check serves a different purpose, so it makes sense to keep them separate. If the account is inactive, there is no reason to run the remaining checks. Similarly, if the transaction amount exceeds the permitted limit, the request should stop there. This is where Chain of Responsibility fits naturally.
Example 2: Spring Security Filter Chain (You Already Use This)
When an HTTP request hits a Spring Boot application secured with Spring Security, it passes through a filter chain. Each filter — authentication, CSRF protection, authorization — does its job and either continues the chain or stops the request.
That's Chain of Responsibility in the standard Spring ecosystem. The servlet filter chain is one of the most widely used implementations of this pattern in Java.
Example 3: Customer Support Escalation
You call customer support. The IVR (front-line handler) tries to resolve your issue. If it can't, it forwards you to a human agent. If the agent can't help, they escalate to a specialist. You, the caller, just initiate the request — the chain figures out who handles it.
Quick Summary for Freshers
| Real-World Situation | Handlers | Stop Condition |
|---|---|---|
| Payment gateway | Validation → Account → Limit → Risk | Any check fails |
| Spring Security | Authentication → CSRF → Authorization | Request rejected or unauthorized |
| Customer support | IVR → Agent → Specialist | Issue resolved |
| Expense approval | Manager → Finance → Director | Approved or rejected at any stage |
Once you see it in a payment pipeline, you'll see it everywhere. That's why Chain of Responsibility is one of the most practical behavioral design patterns when you have multiple independent checks.
Violation: Why Not Put Everything in One Service Method?
Let's look at a simple implementation without the pattern.
public void processPayment(PaymentRequest request) {
if (request == null) {
throw new IllegalArgumentException("Invalid request");
}
if (!accountService.isActive(request.getAccountId())) {
throw new IllegalStateException("Account is inactive");
}
if (request.getAmount() > 50000) {
throw new IllegalArgumentException("Transaction limit exceeded");
}
if (fraudService.isSuspicious(request)) {
throw new IllegalStateException("Transaction rejected");
}
paymentService.execute(request);
}
The Problem: There is nothing inherently wrong with this code when the requirements are small. The problem appears when more checks are added and the method becomes responsible for too many things.
Imagine adding merchant verification, KYC checks, device validation, transaction velocity rules, and additional fraud checks. The service gradually becomes harder to maintain, and testing each rule independently becomes less convenient. This violates the Open/Closed principle — the method is closed to extension because every new rule forces a modification.
With Chain of Responsibility, each validation can live in its own handler. The main flow then focuses on connecting the handlers and starting the request.
Solution: Implementing Chain of Responsibility in Java
Before building the payment example, let's start with something smaller. This will make the main idea easier to understand.
Step 1: Create a Handler Interface
public interface SupportHandler {
void setNext(SupportHandler next);
void handle(String issue);
}
Every handler follows the same contract. It can receive an issue and, when necessary, forward it to another handler.
Step 2: Create a Base Handler
public abstract class BaseSupportHandler
implements SupportHandler {
protected SupportHandler next;
@Override
public void setNext(SupportHandler next) {
this.next = next;
}
protected void forward(String issue) {
if (next != null) {
next.handle(issue);
} else {
System.out.println(
"No handler found for: " + issue
);
}
}
}
The base class contains the common forwarding logic, so individual handlers do not need to repeat it.
Step 3: Create Concrete Handlers
public class TechnicalSupportHandler
extends BaseSupportHandler {
@Override
public void handle(String issue) {
if ("technical".equalsIgnoreCase(issue)) {
System.out.println(
"Technical team is handling the issue."
);
} else {
forward(issue);
}
}
}
public class BillingSupportHandler
extends BaseSupportHandler {
@Override
public void handle(String issue) {
if ("billing".equalsIgnoreCase(issue)) {
System.out.println(
"Billing team is handling the issue."
);
} else {
forward(issue);
}
}
}
Step 4: Connect the Handlers
public class ChainDemo {
public static void main(String[] args) {
SupportHandler technical =
new TechnicalSupportHandler();
SupportHandler billing =
new BillingSupportHandler();
technical.setNext(billing);
technical.handle("technical");
technical.handle("billing");
technical.handle("general");
}
}
Output:
Technical team is handling the issue.
Billing team is handling the issue.
No handler found for: general
Notice that the client starts processing from the first handler. It does not need to decide which handler should receive each request. The handlers themselves determine whether to process or forward it.
For Beginners: The Airport Checkpoint Analogy
Think of an airport. You pass through check-in, then security screening, then passport control, then the boarding gate. Each checkpoint has one job. If you fail security screening, you don't proceed to passport control — the chain stops. Each checkpoint doesn't need to know how the others work; it only needs to do its own job and pass you to the next checkpoint.
Chain of Responsibility is the airport checkpoints. Your request is the passenger.
A Practical Payment Validation Example
Now let's apply the same idea to a payment application. We will create a chain with request validation, account verification, transaction-limit checking, and fraud checking.
For this example, the handlers stop processing by throwing an exception when a mandatory check fails. In a larger application, you could return a validation result instead, especially when different rejection reasons need to be handled consistently.
1. Create the PaymentRequest Class
public class PaymentRequest {
private final String accountId;
private final double amount;
public PaymentRequest(String accountId, double amount) {
this.accountId = accountId;
this.amount = amount;
}
public String getAccountId() {
return accountId;
}
public double getAmount() {
return amount;
}
}
The request contains the account identifier and payment amount. For a production financial
application, use BigDecimal or integer minor units rather than double to
represent money.
2. Create the PaymentHandler Interface
public interface PaymentHandler {
void setNext(PaymentHandler next);
void handle(PaymentRequest request);
}
3. Create the Abstract Payment Handler
public abstract class AbstractPaymentHandler
implements PaymentHandler {
private PaymentHandler next;
@Override
public void setNext(PaymentHandler next) {
this.next = next;
}
protected void forward(PaymentRequest request) {
if (next != null) {
next.handle(request);
}
}
}
This class keeps the next-handler reference and provides a common method for continuing the chain.
4. Validate the Request
public class RequestValidationHandler
extends AbstractPaymentHandler {
@Override
public void handle(PaymentRequest request) {
if (request == null
|| request.getAccountId() == null
|| request.getAccountId().isBlank()
|| !Double.isFinite(request.getAmount())
|| request.getAmount() <= 0) {
throw new IllegalArgumentException(
"Invalid payment request"
);
}
System.out.println("Request validation passed.");
forward(request);
}
}
This handler rejects a missing request, an empty account identifier, or an invalid amount. The next handler runs only when the request passes these checks.
5. Verify the Account
public class AccountVerificationHandler
extends AbstractPaymentHandler {
@Override
public void handle(PaymentRequest request) {
// Demo rule. Replace with an account service
// or repository lookup in a real application.
boolean active =
"ACC100".equals(request.getAccountId());
if (!active) {
throw new IllegalStateException(
"Account is inactive or not found"
);
}
System.out.println("Account verification passed.");
forward(request);
}
}
In a real application, account status should come from a trusted account service or database. The example uses a fixed account identifier simply to demonstrate the pattern.
6. Check the Transaction Limit
public class TransactionLimitHandler
extends AbstractPaymentHandler {
private static final double MAX_AMOUNT = 50000.00;
@Override
public void handle(PaymentRequest request) {
if (request.getAmount() > MAX_AMOUNT) {
throw new IllegalArgumentException(
"Transaction limit exceeded"
);
}
System.out.println("Transaction limit check passed.");
forward(request);
}
}
The handler checks the transaction against a simple maximum amount. Real transaction limits may depend on account type, customer risk, merchant rules, and other business requirements.
7. Perform a Basic Risk Check
public class FraudCheckHandler
extends AbstractPaymentHandler {
@Override
public void handle(PaymentRequest request) {
// Simplified demonstration rule only.
boolean suspicious = request.getAmount() > 40000;
if (suspicious) {
throw new IllegalStateException(
"Transaction requires risk review"
);
}
System.out.println("Fraud check passed.");
forward(request);
}
}
This is not a real fraud detection algorithm. It simply demonstrates how an additional handler can apply a separate business rule without changing the other handlers.
8. Connect the Chain and Run the Application
public class PaymentApplication {
public static void main(String[] args) {
PaymentHandler validation =
new RequestValidationHandler();
PaymentHandler account =
new AccountVerificationHandler();
PaymentHandler limit =
new TransactionLimitHandler();
PaymentHandler fraud =
new FraudCheckHandler();
validation.setNext(account);
account.setNext(limit);
limit.setNext(fraud);
PaymentRequest request =
new PaymentRequest("ACC100", 10000.00);
try {
validation.handle(request);
// Actual payment execution belongs here,
// after all mandatory checks have passed.
System.out.println(
"Payment is ready for processing."
);
} catch (IllegalArgumentException
| IllegalStateException exception) {
System.out.println(
"Payment rejected: " + exception.getMessage()
);
}
}
}
Expected output:
Request validation passed.
Account verification passed.
Transaction limit check passed.
Fraud check passed.
Payment is ready for processing.
If you change the amount to 60000.00, the transaction-limit handler throws an exception.
The fraud handler does not run, and the application does not reach the payment-execution statement.
For Experts: Chain of Responsibility vs. Strategy (Know the Difference)
This is a common interview trap.
| Aspect | Chain of Responsibility | Strategy |
|---|---|---|
| Purpose | Pass a request through handlers | Choose an algorithm or behavior |
| Execution | Multiple handlers may run | Usually one selected strategy runs |
| Example | Payment validation | Payment provider selection |
| Control | Handlers can forward or stop the request | The client or context selects a strategy |
Rule of thumb: Chain of Responsibility passes a request through multiple handlers. Strategy selects one algorithm. For example, you could use Chain of Responsibility to validate a payment request and Strategy to choose which payment provider should process it. Both patterns can work together without replacing one another.
Using the Same Idea in Spring Boot
In Spring Boot, you do not always need to connect handlers manually. When several classes implement the same interface, Spring can inject them as a list.
This is convenient when the validation steps are configured as Spring beans and the order is important.
Create a Validation Result
public record ValidationResult(
boolean valid,
String message) {
public static ValidationResult success() {
return new ValidationResult(true, "OK");
}
public static ValidationResult failure(String message) {
return new ValidationResult(false, message);
}
}
Define the Validator Interface
public interface PaymentValidator {
ValidationResult validate(PaymentRequest request);
}
Implement an Amount Validator
import org.springframework.stereotype.Component;
@Component
public class AmountValidator implements PaymentValidator {
@Override
public ValidationResult validate(PaymentRequest request) {
if (request == null
|| !Double.isFinite(request.getAmount())
|| request.getAmount() <= 0) {
return ValidationResult.failure(
"Amount must be a positive, finite value"
);
}
return ValidationResult.success();
}
}
Create the Validation Pipeline
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class PaymentValidationChain {
private final List<PaymentValidator> validators;
public PaymentValidationChain(
List<PaymentValidator> validators) {
this.validators = validators;
}
public ValidationResult validate(PaymentRequest request) {
for (PaymentValidator validator : validators) {
ValidationResult result =
validator.validate(request);
if (!result.valid()) {
return result;
}
}
return ValidationResult.success();
}
}
Spring automatically injects the registered validator beans. If the order matters, annotate the
implementations with @Order and use an explicit ordering convention.
This list-based approach is often convenient for Spring Boot validation pipelines. Strictly speaking, it is a pipeline-style implementation of the same idea rather than the linked-handler implementation shown earlier. Choose the version that best matches your application's requirements.
Chain of Responsibility vs. Strategy Pattern
These two patterns can appear similar because both use interfaces and separate implementations. The difference becomes clear when you look at how they are used.
| Point | Chain of Responsibility | Strategy |
|---|---|---|
| Purpose | Pass a request through handlers | Choose an algorithm or behavior |
| Execution | Multiple handlers may run | Usually one selected strategy runs |
| Example | Payment validation | Payment provider selection |
| Control | Handlers can forward or stop the request | The client or context selects a strategy |
For example, you could use Chain of Responsibility to validate a payment request and Strategy to choose which payment provider should process it. Both patterns can work together without replacing one another.
Advantages and Disadvantages
| Advantages | Disadvantages |
|---|---|
|
|
Frequently Asked Interview Questions
1. What is Chain of Responsibility in Java?
It is a behavioral design pattern in which a request moves through a sequence of handlers. Each handler can process the request, forward it, or stop further processing.
2. Can multiple handlers process the same request?
Yes. In a validation pipeline, multiple handlers can inspect the same request. In other implementations, one handler may take responsibility and prevent subsequent handlers from running.
3. What happens if no handler accepts a request?
The result depends on the implementation. The application can return an unhandled result, invoke a fallback handler, or raise an error. The behavior should be explicitly defined.
4. Is Chain of Responsibility a behavioral design pattern?
Yes. It focuses on how requests and responsibilities are distributed among collaborating objects.
5. What is the main advantage of this pattern?
It reduces coupling between the client and individual handlers and makes it easier to add, remove, or reorder processing steps. Each handler owns a single responsibility and can be tested independently.
6. Is Spring Security an example of Chain of Responsibility?
Spring Security's servlet security filter chain is a practical example of sequential request processing through multiple filters. Each filter can perform security-related work and continue the filter chain when appropriate.
7. How is Chain of Responsibility different from Strategy?
Chain of Responsibility distributes a request across a sequence of handlers, while Strategy allows a client to select a particular algorithm or behavior.
8. Can this pattern be used in microservices?
Yes. It can be used inside a microservice for validation, authorization, routing decisions, and request processing. Communication between separate microservices, however, introduces network and distributed-systems concerns that the pattern alone does not solve.
9. Does Chain of Responsibility follow SOLID principles?
A well-designed implementation can support the Single Responsibility Principle and Open/Closed Principle. Whether it follows SOLID depends on how responsibilities, dependencies, and extension points are designed.
10. When should you avoid this pattern?
Avoid it when there is only one simple operation, when a normal conditional is easier to understand, or when the added handler structure introduces more complexity than value.
Your Chain of Responsibility Checklist
Before refactoring your if-else ladder into a chain, ask:
- Do I have multiple independent checks? If each rule is genuinely separate, a chain helps.
- Will the rules grow or change? If new checks arrive frequently, a chain is easier to extend.
- Does order matter? If yes, document and test the chain order explicitly.
- What happens when no handler processes the request? Define the fallback behavior clearly.
- Is the complexity worth it? For two static conditionals, a simple
if-elseis better.
Start small. Refactor one growing method. The maintainability gains compound.
Best Practices for Java Developers
- Keep each handler focused on one responsibility.
- Make the handler order explicit whenever business rules depend on it.
- Decide clearly whether a handler forwards, rejects, or terminates a request.
- Avoid putting every business rule into a single handler.
- Write unit tests for both successful and failed validation scenarios.
- Keep Spring singleton handlers stateless where possible.
- Handle external-service timeouts and failures separately from business rejections.
- Use meaningful validation results or domain exceptions instead of unclear generic errors.
Conclusion
The Chain of Responsibility pattern isn't about complexity — it's about separated responsibilities. When a request must pass through multiple independent checks, Chain of Responsibility gives each check its own class and lets you add, remove, or reorder them without touching the client. It trades a small upfront design decision for long-term flexibility.
I'll be honest: I don't use Chain of Responsibility in every project. Small apps with two static
conditionals don't need it. But for anything that touches payments, authentication, approval
workflows, or request-processing pipelines — where rules grow and change — it has saved me from
rewriting the same large method over and over. Start with the problem, not the pattern. If you find
yourself adding a new if to a method every sprint, Chain of Responsibility is probably
the answer.
The most important lesson is simple: use the pattern when a request genuinely needs to pass through multiple independent responsibilities, not merely to replace every conditional statement with another class.
Related topics: Java Design Patterns with Real-World Examples, Strategy Design Pattern in Java, Factory Design Pattern in Java, Spring Boot Filters and Interceptors, SOLID Principles with Spring Boot Examples, Java Exception Handling Best Practices
No comments:
Post a Comment