Open/Closed: register new rules, edit nothing
Add each new business rule as a new class, without editing working code.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Steps 5–10Payoff
Steps 11–12Recap
Steps 13–14The code: before and after
Before
@dataclass(frozen=True)
class Expense:
employee: str
category: str
amount: int
has_receipt: bool
class ExpensePolicy:
def problems(self, expense: Expense) -> list[str]:
found: list[str] = []
if expense.amount > 25 and not expense.has_receipt:
found.append("receipt needed over 25")
if expense.category == "meals" and expense.amount > 60:
found.append("meals capped at 60")
return found
def main() -> None:
lunch = Expense("Dana", "meals", 80, False)
wine = Expense("Sam", "alcohol", 20, True)
print(ExpensePolicy().problems(lunch))
print(ExpensePolicy().problems(wine))
if __name__ == "__main__":
main()After
@dataclass(frozen=True)
class Expense:
employee: str
category: str
amount: int
has_receipt: bool
class ExpenseRule(Protocol):
def breaks(self, expense: Expense) -> bool: ...
def message(self) -> str: ...
class ReceiptRule:
def breaks(self, expense: Expense) -> bool:
return expense.amount > 25 and not expense.has_receipt
def message(self) -> str:
return "receipt needed over 25"
class MealCapRule:
def breaks(self, expense: Expense) -> bool:
return expense.category == "meals" and expense.amount > 60
def message(self) -> str:
return "meals capped at 60"
class AlcoholRule:
def breaks(self, expense: Expense) -> bool:
return expense.category == "alcohol"
def message(self) -> str:
return "alcohol not refunded"
class ExpensePolicy:
def __init__(self, rules: list[ExpenseRule]) -> None:
self._rules = list(rules)
def problems(self, expense: Expense) -> list[str]:
found: list[str] = []
for rule in self._rules:
if rule.breaks(expense):
found.append(rule.message())
return found
def main() -> None:
lunch = Expense("Dana", "meals", 80, False)
wine = Expense("Sam", "alcohol", 20, True)
rules: list[ExpenseRule] = []
rules.append(ReceiptRule())
rules.append(MealCapRule())
rules.append(AlcoholRule())
policy = ExpensePolicy(rules)
print(policy.problems(lunch))
print(policy.problems(wine))
if __name__ == "__main__":
main()Transcript, step by step
Problem
Step 1: An expense policy Before
An Expense is one claim, with who spent it, the category, the amount in whole dollars, and whether there's a receipt. Expense Policy checks a claim against each rule from the finance team, where a rule is one check of the policy, and collects what's wrong. The main function is the caller, with an eighty dollar lunch without a receipt and a twenty dollar bottle of wine. The lunch breaks both rules, and the wine breaks none, so the second line prints an empty list. Two rules from one team fit easily in one method, so writing it this way made sense.
Step 2: Finance adds a rule Code smell
Finance sends a third rule, and this one says alcohol is never refunded. The quick way opens Expense Policy and adds a third if block to problems. Now the wine claim prints alcohol not refunded instead of an empty list.
Step 3: One method, edited per rule Code smell
Every new rule means opening Expense Policy and editing a method that already works. The method was opened for the receipt rule here, for the meal cap here, and now for alcohol here. Each edit lands in the method that holds every other rule, so whoever reviews the alcohol change has to read the receipt and meal checks again. And the method grows by one more block for every rule finance sends.
Step 4: Open only by editing Principle
The Open Closed Principle, from Bertrand Meyer, says code should be open for extension and closed for modification. In plain words, you add new behavior by adding new code, and the code that already works stays as it is. This method is open too, but only because we edit it every time.
Refactor
Step 5: Refactor first Back it out
Let's take the alcohol rule out again. We'll refactor first, so a new rule can arrive without opening this method, and then add alcohol back.
Step 6: Name what a rule is Extract Interface
First we name what both rules have in common. Expense Rule is a Protocol, Python's way to write an interface, with two methods, where breaks checks a claim and message says what to report. Any class with those two methods fits it without inheriting from it, so the rule classes stay plain. Nothing uses it yet, and the next moves fill it in.
Step 7: The receipt rule Extract Class
The receipt check moves into its own class, Receipt Rule, which has the two methods the Protocol asks for. Its breaks method holds the same condition as before, and message returns the same text. Problems now creates a Receipt Rule and asks it, so both claims still print the same lines.
Step 8: The meal cap rule Extract Class
The meal cap gets the same move, into a class called Meal Cap Rule. Problems now asks two rule objects, one after the other, and the two blocks have exactly the same shape.
Step 9: One loop over the rules Replace Conditional with Polymorphism
Because the two blocks match, one loop over a list of rules can replace both of them. The loop asks each rule through the Protocol, so it doesn't know which rules exist. The list, though, still sits inside the policy, so a new rule would still mean editing this method.
Step 10: The caller registers rules Constructor Injection
So the list moves out, and Expense Policy takes its rules through its constructor, the init method. Main now registers the rules, which means it adds each rule to the list it gives the policy. Both claims go through that one policy, and they print the same two lines as before.
Payoff
Step 11: Alcohol, as a new class Payoff
Now the alcohol rule comes back. It's a new class, Alcohol Rule, with the same two methods as the other rules, so it fits Expense Rule. Main registers it with one added line, and no existing line changes. The wine claim now prints alcohol not refunded, and the lunch still breaks the other two rules.
Step 12: Closed, and still open Payoff
Expense Policy, Receipt Rule and Meal Cap Rule did not change. The three if blocks are gone from the policy, and each rule now sits in its own class, here, here and here. That's the Open Closed Principle, because Expense Policy is closed for modification and open for extension through the rule list. It fits here because finance sends new rules often, and each rule checks an expense without knowing about the others.
Recap
Step 13: Where we started, where we ended Before / after
On the left, every rule is an if block inside problems, so each new rule edits that method. On the right, each rule is a class that fits the Expense Rule Protocol. The policy loops over whatever rules it was given, so it stays closed when finance sends the next one.
Step 14: When to reach for it Takeaways
Reach for it when new rules arrive often, each one stands alone, and different people add them. Skip it when there are two rules that change once a year, because two if blocks read easier than a Protocol and a class per rule. You'll spot the need by a method that grows one more if block with every feature request. Python's logging works this way, because a logger passes each record to every handler you add, and the Logger class never changes.