When a workflow needs more and more rules applied one after another, the code inside a single service grows almost by itself. You see this a lot in systems that are heavy on validation.
Think of an airline booking. Before the ticket is issued:
- Are there enough seats on the plane?
- Is the passenger on the no-fly list?
- Is the passport valid?
- Is the booking amount over a certain limit?
- Is the promo code valid?
- Is a visa needed?
- Does it pass the fraud check?
At the start it is easy to check all of this with a few if statements. But when the rules go from 5 to 10, and from 10 to 20, BookingService slowly turns into a huge method.
This is where Chain of Responsibility helps. The idea:
Make every rule an independent link in a chain, and pass the request through those links one by one.
If a rule fails, the chain stops. If it passes, the request moves to the next rule.
The scenario: booking a flight ticket
Let’s picture the booking flow like this:
graph TD
R[Booking Request] --> F[Flight Check]
F --> S[Seat Check]
S --> N[No-Fly Check]
N --> P[Passport Check]
P --> A[Total Amount Check]
A --> C[Promo Check]
C --> B([Booking Created])Every box here is an independent rule. SeatAvailabilityCheck only cares about the seat count, PassportValidityCheck only about passports, PromoCodeCheck only about promotions. None of them knows how the others work inside.
🔴 The bad approach: putting everything in BookingService
First, the classic approach. If we put all the checks in one service:
@Service
public class BookingService {
public void createBooking(BookingRequest req) {
// 1. Seat availability
int available = seatRepository.getAvailableSeats(
req.flightId(), req.cabinClass());
if (available < req.passengers().size()) {
throw new BookingException("Not enough seats: " + req.flightId());
}
// 2. No-fly check
for (Passenger p : req.passengers()) {
if (noFlyListRepository.isBanned(p.identityNumber())) {
throw new BookingException(
"Passenger is on the no-fly list: " + p.fullName());
}
}
// 3. Passport check
Flight flight = flightRepository.find(req.flightId());
if (flight.isInternational()) {
for (Passenger p : req.passengers()) {
LocalDate requiredUntil = flight.arrivalDate().plusMonths(6);
if (p.passportExpiry().isBefore(requiredUntil)) {
throw new BookingException(
"Passport does not stay valid long enough: " + p.fullName());
}
}
}
// 4. Amount check
BigDecimal total = calculateTotal(req);
if (total.compareTo(new BigDecimal("100000")) > 0) {
if (!req.customer().isVerified()) {
throw new BookingException(
"A verified account is required above 100,000 TL");
}
}
// 5. Promo check
if (req.promoCode() != null) {
PromoCode promo = promoRepository.find(req.promoCode());
if (promo == null || promo.isExpired()) {
throw new BookingException("Invalid promo code");
}
if (!promo.validRoutes().contains(flight.route())) {
throw new BookingException("Promo code is not valid on this route");
}
}
// 6. Infant / unaccompanied minor
// 7. Visa
// 8. Fraud
// 9. ...
bookingRepository.save(new Booking(...));
}
}
The code may not look too big yet. But in real systems the list does not end here. After a while you get something like this:
BookingService
├── Seat check
├── No-fly check
├── Passport check
├── Amount check
├── Promo check
├── Visa check
├── Fraud check
├── Child passenger check
├── Document check
├── Country restriction
└── ...
And BookingService slowly becomes a god object.
The real problems here
Adding a new rule gets harder
When a visa rule arrives for a new country, you have to open the existing service and add one more
if. The more rules, the bigger the service.The tests become tangled
Even if you only want to test the passport check, you may have to deal with all the other dependencies of
BookingService.Changing the order is risky
If the passport check needs the flight, the flight has to be loaded first. Following those dependencies inside one big method gets hard.
Everybody touches the same file
One developer adds the promo check, another adds the fraud check, a third changes the visa rule. When all of them work on the same
BookingService, merge conflicts go up too.
🟢 The good approach: Chain of Responsibility
Now let’s split the rules into independent links. Every rule implements the same interface:
public interface BookingCheck {
void check(BookingContext ctx);
}
If a rule fails, it throws. If it passes, it does nothing and the flow continues.
The same seat check, in two shapes:
// Inside BookingService, between five other rules
int available = seatRepository.getAvailableSeats(
req.flightId(), req.cabinClass());
if (available < req.passengers().size()) {
throw new BookingException("Not enough seats: " + req.flightId());
}
@Component
@Order(20)
public class SeatAvailabilityCheck implements BookingCheck {
private final SeatRepository seatRepository;
public SeatAvailabilityCheck(SeatRepository seatRepository) {
this.seatRepository = seatRepository;
}
@Override
public void check(BookingContext ctx) {
int requested = ctx.getRequest().passengers().size();
int available = seatRepository.getAvailableSeats(
ctx.getFlight().id(), ctx.getRequest().cabinClass());
if (available < requested) {
throw new BookingRejectedException(
RejectReason.NO_SEATS, "Not enough seats in the selected cabin");
}
}
}
The best part of this shape is that every link has its own single responsibility.
1. The Context: carrying data between links
Some rules need data produced by the rules that ran before them:
FlightLoadCheckfinds the flight in the database.SeatAvailabilityCheckuses that flight.TotalAmountCheckcalculates the total.PromoCodeCheckuses both the flight and the total.
Instead of passing all of this into every method, we can keep it in a Context object:
public class BookingContext {
private final BookingRequest request;
private Flight flight;
private BigDecimal totalAmount;
private PromoCode appliedPromo;
public BookingContext(BookingRequest request) {
this.request = request;
}
// getters / setters
}
So one link enriches the context, and the next link can use it:
FlightLoadCheck
│
│ ctx.setFlight(...)
▼
SeatAvailabilityCheck
│
▼
TotalAmountCheck
│
│ ctx.setTotalAmount(...)
▼
PromoCodeCheck
So the context does not only carry the request. It also carries what the pipeline produces along the way.
2. Every rule in its own class
FlightLoadCheck
@Component
@Order(10)
public class FlightLoadCheck implements BookingCheck {
private final FlightRepository flightRepository;
public FlightLoadCheck(FlightRepository flightRepository) {
this.flightRepository = flightRepository;
}
@Override
public void check(BookingContext ctx) {
Flight flight = flightRepository
.find(ctx.getRequest().flightId())
.orElseThrow(() -> new BookingRejectedException(
RejectReason.FLIGHT_NOT_FOUND, "Flight not found"));
if (flight.departureTime().isBefore(LocalDateTime.now().plusHours(2))) {
throw new BookingRejectedException(
RejectReason.CHECK_IN_CLOSED,
"Online sales are closed less than 2 hours before departure");
}
ctx.setFlight(flight);
}
}
The only job of this class is to check the flight. It knows nothing about the other rules.
NoFlyListCheck
@Component
@Order(30)
public class NoFlyListCheck implements BookingCheck {
private final NoFlyListRepository noFlyListRepository;
public NoFlyListCheck(NoFlyListRepository noFlyListRepository) {
this.noFlyListRepository = noFlyListRepository;
}
@Override
public void check(BookingContext ctx) {
for (Passenger passenger : ctx.getRequest().passengers()) {
if (noFlyListRepository.isBanned(passenger.identityNumber())) {
throw new BookingRejectedException(
RejectReason.NO_FLY_LIST,
"Passenger cannot be accepted on this flight: " + passenger.fullName());
}
}
}
}
There was a detail worth noticing in SeatAvailabilityCheck above: it does not load the flight from the database itself. It uses the Flight that the previous link put into the context (ctx.getFlight()). That gives the steps a controlled flow of data.
PassportValidityCheck
@Component
@Order(40)
public class PassportValidityCheck implements BookingCheck {
@Override
public void check(BookingContext ctx) {
if (!ctx.getFlight().isInternational()) {
return;
}
LocalDate requiredUntil = ctx.getFlight().arrivalDate().plusMonths(6);
for (Passenger passenger : ctx.getRequest().passengers()) {
if (passenger.passportExpiry().isBefore(requiredUntil)) {
throw new BookingRejectedException(
RejectReason.PASSPORT_EXPIRING,
"The passport must stay valid for 6 months after arrival: "
+ passenger.fullName());
}
}
}
}
That return matters. On a domestic flight this check has nothing to do, so we move on to the next link. This is one of the natural behaviours of Chain of Responsibility:
“This rule does not apply to this request, carry on.”
3. A link can enrich the context
Now the rule that calculates the total:
@Component
@Order(50)
public class TotalAmountCheck implements BookingCheck {
private static final BigDecimal THRESHOLD = new BigDecimal("100000");
private final FareCalculator fareCalculator;
public TotalAmountCheck(FareCalculator fareCalculator) {
this.fareCalculator = fareCalculator;
}
@Override
public void check(BookingContext ctx) {
BigDecimal total = fareCalculator.calculate(
ctx.getRequest(), ctx.getFlight());
ctx.setTotalAmount(total);
if (total.compareTo(THRESHOLD) > 0
&& !ctx.getRequest().customer().isVerified()) {
throw new BookingRejectedException(
RejectReason.VERIFICATION_REQUIRED,
"A verified account is required for bookings above 100,000 TL");
}
}
}
We do two things here: we calculate the total, and we put it into the context. The next links can now use ctx.getTotalAmount().
4. PromoCodeCheck
Now a rule that uses what the earlier links produced:
@Component
@Order(60)
public class PromoCodeCheck implements BookingCheck {
private final PromoRepository promoRepository;
public PromoCodeCheck(PromoRepository promoRepository) {
this.promoRepository = promoRepository;
}
@Override
public void check(BookingContext ctx) {
String code = ctx.getRequest().promoCode();
if (code == null) {
return;
}
PromoCode promo = promoRepository
.find(code)
.filter(p -> !p.isExpired())
.orElseThrow(() -> new BookingRejectedException(
RejectReason.INVALID_PROMO, "Invalid promo code"));
if (!promo.validRoutes().contains(ctx.getFlight().route())) {
throw new BookingRejectedException(
RejectReason.PROMO_ROUTE_MISMATCH,
"Promo code is not valid on this route");
}
if (promo.minAmount().compareTo(ctx.getTotalAmount()) > 0) {
throw new BookingRejectedException(
RejectReason.PROMO_MIN_NOT_MET,
"The minimum ticket amount for this promo is not reached");
}
ctx.setAppliedPromo(promo);
}
}
PromoCodeCheck takes two different pieces of data from earlier links:
graph LR
F[FlightLoadCheck] -->|"ctx.flight"| P[PromoCodeCheck]
T[TotalAmountCheck] -->|"ctx.totalAmount"| PSo the order in the chain is not random. The flight must be loaded first, then the total calculated, and only then the promo checked.
5. The pipeline that runs the chain
Now we have to bring all these links together. Spring makes this easy: we can inject every bean that implements BookingCheck as a List<BookingCheck>. Thanks to @Order, Spring hands them over in the right order:
@Service
public class BookingCheckPipeline {
private final List<BookingCheck> checks;
public BookingCheckPipeline(List<BookingCheck> checks) {
this.checks = checks;
}
public BookingContext run(BookingRequest request) {
BookingContext ctx = new BookingContext(request);
for (BookingCheck check : checks) {
check.check(ctx);
}
return ctx;
}
}
On the Spring side we end up with roughly this list:
@Order(10) FlightLoadCheck
@Order(20) SeatAvailabilityCheck
@Order(30) NoFlyListCheck
@Order(40) PassportValidityCheck
@Order(50) TotalAmountCheck
@Order(60) PromoCodeCheck
The only job of the pipeline is to run the chain. That is all.
What if you want to collect all the errors instead of stopping at the first one?
The pipeline above is fail-fast: the first failing rule throws, and the chain stops there. The user fixes that error, sends the request again, and hits the next one.
In form validation you usually want to show every problem at once. For that, the rules have to return a list of violations instead of throwing:
public interface BookingCheck {
List<Violation> check(BookingContext ctx);
}
And the pipeline collects as it goes:
public BookingContext run(BookingRequest request) {
BookingContext ctx = new BookingContext(request);
List<Violation> violations = new ArrayList<>();
for (BookingCheck check : checks) {
violations.addAll(check.check(ctx));
}
if (!violations.isEmpty()) {
throw new BookingRejectedException(violations);
}
return ctx;
}
One warning: this is only safe when the rules are independent. If FlightLoadCheck fails, ctx.getFlight() stays null and the next rules blow up with a NullPointerException. What works well in practice is a mix: the steps that load data stay fail-fast, and the pure validation steps collect.
Why does @Order matter?
@Order is not just a cosmetic detail here. Some rules in the chain depend on others:
FlightLoadCheck TotalAmountCheck
↓ ↓
ctx.flight ctx.totalAmount
↓ ↓
PassportValidityCheck PromoCodeCheck
The flight has to be in the context before the passport check runs. In the same way, the total has to be calculated before the promo check. So the order is decided by the data dependencies.
Going 10, 20, 30... also helps in practice. If tomorrow you need a new check in between, you can use something like @Order(45).
The ordering creates a hidden dependency
This approach has a cost: SeatAvailabilityCheck relies on ctx.getFlight() being filled, but it never says so anywhere. The dependency lives only in the gap between two @Order numbers.
If somebody adds a new check with @Order(5), or renumbers the existing ones, the compiler will not warn you. You find out at runtime with a NullPointerException.
Two simple things help:
Do not let the context getters stay silent. Make
getFlight()fail with a clear message instead of returning null:public Flight getFlight() { return Objects.requireNonNull(flight, "FlightLoadCheck must run before this rule"); }Test the order of the chain. The tip below shows how.
6. BookingService is tiny now
Once all these rules move into their own classes, look how small the service becomes:
@Service
public class BookingService {
private final BookingCheckPipeline pipeline;
private final BookingRepository bookingRepository;
public BookingService(BookingCheckPipeline pipeline,
BookingRepository bookingRepository) {
this.pipeline = pipeline;
this.bookingRepository = bookingRepository;
}
@Transactional
public Booking createBooking(BookingRequest request) {
BookingContext ctx = pipeline.run(request);
return bookingRepository.save(Booking.from(ctx));
}
}
The 300 lines of validation logic are gone from here. The service only does this:
Request
↓
Pipeline
↓
BookingContext
↓
Save
More importantly, BookingService does not even know which rules exist. That is an important difference.
How hard is it to add a new rule?
Say a new rule arrives:
An ETA is required on flights to the United Kingdom.
In the old approach you would open BookingService and add one more if. With Chain of Responsibility you add a new class:
@Component
@Order(45)
public class EtaRequirementCheck implements BookingCheck {
@Override
public void check(BookingContext ctx) {
if (!ctx.getFlight().isToUnitedKingdom()) {
return;
}
if (!ctx.getRequest().customer().hasValidEta()) {
throw new BookingRejectedException(
RejectReason.ETA_REQUIRED, "An ETA is required for UK flights");
}
}
}
And that is it. BookingService did not change, BookingCheckPipeline did not change, the other rules did not change. Only a new link was added. This is one of the strongest sides of the pattern.
Unit tests: testing every rule on its own
Another nice benefit of Chain of Responsibility is testability. If we only want to test SeatAvailabilityCheck, we do not have to mock the repositories of the other rules. We only give this class the dependency it needs:
@Test
void rejectsTheBookingWhenThereAreNotEnoughSeats() {
when(seatRepository.getAvailableSeats("TK1923", CabinClass.ECONOMY))
.thenReturn(1);
var check = new SeatAvailabilityCheck(seatRepository);
BookingContext context = contextWithPassengers("TK1923", 3);
assertThatThrownBy(() -> check.check(context))
.isInstanceOf(BookingRejectedException.class);
}
What the test does is very clear:
3 passengers
↓
1 seat
↓
SeatAvailabilityCheck
↓
BookingRejectedException
No no-fly check, no promo check, no passport check. They have nothing to do with this test. That makes the tests both faster and easier to read.
Testing the links one by one is not enough. The order itself is a behaviour, and it deserves a test:
@Test
void theChainRunsInTheRightOrder() {
List<String> order = pipeline.getChecks().stream()
.map(c -> c.getClass().getSimpleName())
.toList();
assertThat(order).containsExactly(
"FlightLoadCheck",
"SeatAvailabilityCheck",
"NoFlyListCheck",
"PassportValidityCheck",
"TotalAmountCheck",
"PromoCodeCheck");
}
This test fails as soon as somebody breaks an @Order value or puts a new rule in the wrong place — before you meet a null Flight in production.
Using it together with guard clauses
There is a nice link here with the guard clauses and early return post. For example:
@Override
public void check(BookingContext ctx) {
if (!ctx.getFlight().isInternational()) {
return;
}
// The real passport check
}
That is a guard clause. So:
- Chain of Responsibility splits the big flow into parts.
- Guard clauses keep the flow inside each part flat.
The two are not alternatives. They work very well together.
Dynamic rules with feature flags
Another nice thing about Chain of Responsibility is that it works well with Spring’s conditional beans. Say we only want to run the fraud check in certain environments:
@Component
@Order(70)
@ConditionalOnProperty(name = "checks.fraud.enabled", havingValue = "true")
public class FraudCheck implements BookingCheck {
@Override
public void check(BookingContext ctx) {
// Fraud check
}
}
The configuration:
checks.fraud.enabled=true
checks:
fraud:
enabled: true
When the property is on, the bean joins the chain. When it is off, the bean is never created, and for the pipeline it is as if the fraud check does not exist. This is a clean way to add feature flags to the chain.
This is the same mechanism as the single-tenant setup in the Adapter and Strategy post: @ConditionalOnProperty decides at startup whether a bean exists at all. There it picks which adapter runs; here it picks which rule is in the chain.
When should you use Chain of Responsibility?
You do not need it for every if block. If you have five simple checks, guard clauses are probably enough. But once the rules start to grow:
- Every rule has its own dependencies.
- The rules are built by different teams.
- New rules are added often.
- The order of the rules matters.
- Some rules are switched on and off with feature flags.
- The same rules are reused in other flows.
- One service is turning into hundreds of lines of validation code.
Then Chain of Responsibility starts to make real sense.
| Situation | Approach |
|---|---|
| A few simple, independent checks | Guard clauses |
| Many rules, but all working on the same data | Simplify the method first |
| Rules with their own dependencies, order and feature flags | Chain of Responsibility / pipeline |
The border is not exact. There is no maths saying “we hit 5 rules, so it must be Chain of Responsibility now”. The real question is:
Can these rules still be understood easily inside one method?
If the answer is no, it is time to think about splitting them.
Where do we see this in the industry?
The pattern feels natural in any system with checks that run one after another. In airline systems, flows like this fit it very well:
Booking Validation
↓
Check-in Validation
↓
Document Check
↓
Boarding Rules
But it is not limited to that. The filter chain of Spring Security carries the same core idea:
graph LR
R[Request] --> A[Authentication Filter]
A --> Z[Authorization Filter]
Z --> C[CSRF Filter]
C --> D["…"]
D --> App([Application])API gateway filters, HTTP middleware, request validation pipelines and various fraud or risk checks are all close to the same idea.
The idea underneath
If we had to put Chain of Responsibility in one sentence:
Instead of managing a big set of rules in one place, make every rule an independent link and move the request along the chain.
What we end up with:
- If a rule fails, the chain stops there.
- If a rule has nothing to do for this request, it quietly moves on.
- Every rule is tested in its own class.
- When a new rule arrives, you add a new link instead of changing the existing services.
So the goal is not only to split the code. The goal is to make changing rules independent of each other.
That way, instead of a 300-line BookingService, we get a pipeline of small, testable rules that each do one job.

Comments