Skip to main content
Low-Level Design · lesson 14 of 18
Open the Low-Level Design page →
Low-Level Design intermediate Lesson 14 of 18

Chain of Responsibility in Python: Expense Approvals Without an if/elif Ladder

Add a rule to an if/elif approval ladder and watch it never fire, then rebuild it as a Chain of Responsibility where each handler approves or passes on.

By SkillByExample · Published

lld design-patterns python chain-of-responsibility

Approval rules grow over time: a limit per role, then a special case for travel, then a blocked vendor. Written as one if/elif, every new rule has to find its exact place in the ladder.

An approval ladder with one new rule

Finance now wants to check every travel expense. The new branch went in where it seemed natural.

# approval_ladder.py: one if/elif ladder decides who approves an expense
def approve(amount, category):
    if amount <= 500:
        return "team lead"
    elif amount <= 5_000:
        return "manager"
    elif amount <= 20_000:
        return "director"
    elif category == "travel":                # new rule: finance checks all travel
        return "finance"
    else:
        return "rejected"

for amount, category in [(120, "books"), (300, "travel"), (8_000, "travel"), (90_000, "laptops")]:
    print(f"{category:>8} {amount:>6} -> {approve(amount, category)}")
$ python approval_ladder.py
   books    120 -> team lead
  travel    300 -> team lead
  travel   8000 -> director
 laptops  90000 -> rejected

Neither travel expense reached finance. elif stops at the first match, and the 300 and 8,000 travel claims matched an amount rule before reaching the travel check. That branch only runs for travel above 20,000. The ladder’s order is its logic, so adding a rule means re-reading every branch above it.

Chain of Responsibility: approve or pass it on

Each rule becomes a handler with one job: handle the request, or give it to the next handler.

# chain.py: each handler approves what it can and passes the rest to the next one
class Handler:
    def __init__(self, next_handler=None):
        self.next = next_handler
    def handle(self, amount, category):
        if self.next is None:
            return "rejected: nobody could approve this"
        return self.next.handle(amount, category)

class Limit(Handler):
    """Approves anything up to its limit, otherwise passes it on."""
    def __init__(self, who, limit, next_handler=None):
        super().__init__(next_handler)
        self.who, self.limit = who, limit
    def handle(self, amount, category):
        if amount <= self.limit:
            return self.who
        return super().handle(amount, category)

class TravelCheck(Handler):
    def handle(self, amount, category):
        if category == "travel":
            return "finance"
        return super().handle(amount, category)

managers = Limit("team lead", 500, Limit("manager", 5_000, Limit("director", 20_000)))
chain = TravelCheck(managers)                 # the new rule goes first, nothing else changes

for amount, category in [(120, "books"), (300, "travel"), (8_000, "travel"), (90_000, "laptops")]:
    print(f"{category:>8} {amount:>6} -> {chain.handle(amount, category)}")
$ python chain.py
   books    120 -> team lead
  travel    300 -> finance
  travel   8000 -> finance
 laptops  90000 -> rejected: nobody could approve this

Both travel claims now go to finance, because TravelCheck was put at the front of the chain. The three Limit handlers weren’t touched, and the order of the rules is visible in one place: the line that builds the chain. The end of the chain is explicit too. The base Handler returns a clear rejection when there’s no next handler, so the 90,000 laptop order says why it failed.

The end of the chain, done wrong

Without that explicit end, a request that nobody handles falls off the chain.

# chain_unhandled.py: a chain whose last handler passes the request on returns None
class Limit:
    def __init__(self, who, limit, next_handler=None):
        self.who, self.limit, self.next = who, limit, next_handler
    def handle(self, amount):
        if amount <= self.limit:
            return self.who
        if self.next:
            return self.next.handle(amount)
        # no else: falling off the end returns None

chain = Limit("team lead", 500, Limit("manager", 5_000))
decision = chain.handle(9_000)
print("decision:", decision)
if decision:                                  # a caller that treats "something" as approved
    print("paid")
else:
    print("not paid, and nobody was told why")
$ python chain_unhandled.py
decision: None
not paid, and nobody was told why

Python returns None from a function that ends without a return, so the 9,000 claim got no decision at all. The caller couldn’t tell a rejection from a bug. Give the last link a real answer (as chain.py does) or raise an error; don’t let “nothing” be a possible outcome.

The same shape appears in web servers as middleware: each layer answers the request or calls the next one, and its order matters just as much.

Practice

1. Add a CFO who can approve up to 50,000, after the director.
# p19_1.py: insert a CFO for amounts up to 50,000 between the director and the end
class Limit:
    def __init__(self, who, limit, next_handler=None):
        self.who, self.limit, self.next = who, limit, next_handler
    def handle(self, amount):
        if amount <= self.limit:
            return self.who
        if self.next is None:
            return "rejected: nobody could approve this"
        return self.next.handle(amount)

chain = Limit("team lead", 500, Limit("manager", 5_000,
        Limit("director", 20_000, Limit("CFO", 50_000))))
for amount in (400, 30_000, 90_000):
    print(f"{amount:>6} -> {chain.handle(amount)}")
$ python p19_1.py
   400 -> team lead
 30000 -> CFO
 90000 -> rejected: nobody could approve this

The CFO is one more link at the end of the chain. No existing handler changed, and 90,000 still reaches the explicit rejection.

2. Build the chain from a config list, and print which handlers each request passed through.
# p19_2.py: build the chain from a config list, and record which handlers saw the request
class Limit:
    def __init__(self, who, limit, next_handler=None):
        self.who, self.limit, self.next = who, limit, next_handler
    def handle(self, amount, seen):
        seen.append(self.who)
        if amount <= self.limit:
            return self.who
        if self.next is None:
            return "rejected"
        return self.next.handle(amount, seen)

config = [("team lead", 500), ("manager", 5_000), ("director", 20_000)]
chain = None
for who, limit in reversed(config):           # build from the end so each links to the next
    chain = Limit(who, limit, chain)

for amount in (300, 12_000, 25_000):
    seen = []
    print(f"{amount:>6} -> {chain.handle(amount, seen):<9} via {' > '.join(seen)}")
$ python p19_2.py
   300 -> team lead via team lead
 12000 -> director  via team lead > manager > director
 25000 -> rejected  via team lead > manager > director

Building from the reversed list lets each new handler point at the one created before it. The trail shows the cost of a chain: a large claim visits every handler before an answer comes back.

3. Add a handler that refuses blocked vendors before any approval limit is checked.
# p19_3.py: a handler can also stop the chain early: a blocked vendor is refused first
class Handler:
    def __init__(self, next_handler=None):
        self.next = next_handler
    def handle(self, req):
        return self.next.handle(req) if self.next else "rejected: nobody could approve this"

class BlockedVendor(Handler):
    BLOCKED = {"ShadyCorp"}
    def handle(self, req):
        if req["vendor"] in self.BLOCKED:
            return f"refused: {req['vendor']} is blocked"
        return super().handle(req)

class Limit(Handler):
    def __init__(self, who, limit, next_handler=None):
        super().__init__(next_handler)
        self.who, self.limit = who, limit
    def handle(self, req):
        return self.who if req["amount"] <= self.limit else super().handle(req)

chain = BlockedVendor(Limit("team lead", 500, Limit("manager", 5_000)))
print(chain.handle({"amount": 100, "vendor": "ShadyCorp"}))
print(chain.handle({"amount": 100, "vendor": "PaperCo"}))
$ python p19_3.py
refused: ShadyCorp is blocked
team lead

A handler doesn’t have to pass the request on. BlockedVendor ends the chain early, so the small amount from a blocked vendor is refused even though a team lead could have approved it.

Frequently Asked Questions

What is the Chain of Responsibility pattern?
Chain of Responsibility passes a request along a line of handler objects. Each handler either deals with the request or hands it to the next one, so the sender doesn't need to know which handler will answer. Expense approvals, web middleware and logging levels are common examples.
Why does adding a rule to an if/elif chain break it?
An if/elif chain stops at the first condition that matches, so a new rule only fires if it's placed before every condition that could catch the same request. A travel rule added at the bottom of an amount ladder never runs for small amounts, because the amount check matches first. The order is the logic, and nothing warns you when it's wrong.
What happens when no handler in the chain handles a request?
If the last handler simply passes the request on, the call returns None in Python, and a caller may misread that as a decision. Make the end of the chain explicit: give the base handler a final answer such as 'rejected: nobody could approve this', or raise an error, so an unhandled request is always visible.
How is Chain of Responsibility related to web middleware?
Web middleware is a Chain of Responsibility: each middleware function can answer the request itself, for example rejecting a missing login, or call the next one in the chain. The ordering problems are the same too: authentication has to sit before the handlers it protects.