I'll Be Backend
Principles · Python

Open/Closed: register new rules, edit nothing

Add each new business rule as a new class, without editing working code.

14 steps4 chapters~5 min at 1.25×#solid#open closed#rules#extension
Language
JavaPython+10 soon
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–10

Payoff

Steps 11–12

Recap

Steps 13–14

The 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.