Low-Level Design · lesson 14 of 18
Beginner
4Intermediate
10- 5 Design a Parking Lot: Allocating and Freeing Spots
- 6 Design an Elevator System: Scheduling Requests
- 7 Design a Library Management System: Borrowing and Returning Books
- 8 Design an Expense-Splitting App: Balances and Minimal Settlements
- 9 Design a URL Shortener: Sequential Codes vs Real Hash Collisions
- 10 Wrapping Objects: Adapter, Facade, and Proxy in Python
- 11 The Composite Pattern in Python: Sizing a File Tree Without isinstance
- 12 The State Pattern in Python: Refactoring a Vending Machine Into State Classes
- 13 Undo and Redo in Python: the Memento and Command Patterns Compared
- 14 Chain of Responsibility in Python: Expense Approvals Without an if/elif Ladder
Advanced
4- 15 Concurrency in LLD: A Real Race Condition in the Parking Lot, Then the Fix
- 16 The Observer Pattern in Practice: A Stock Ticker Notifying Multiple Displays
- 17 Design Patterns That Are Just Language Features: Strategy, Command, Iterator
- 18 Abstract Factory and Prototype in Python: Matching Families and Cloning Objects
Also part of Low-Level Design
12- + SOLID Principles in Java from Java
- + Composition vs Inheritance in Java from Java
- + Design Patterns in Java from Java
- + Python Design Patterns from Python
- + Design Patterns in TypeScript from TypeScript
- + JavaScript Design Patterns from JavaScript
- + Design Patterns in C# from C#
- + Design Patterns in C++ from C++
- + Design Patterns in Go from Go
- + Design Patterns in Rust from Rust
- + Abstract Classes in Java from Java
- + Sealed Classes and Interfaces in Java from Java
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
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.