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
class Expense {
constructor(
readonly employee: string,
readonly category: string,
readonly amount: number,
readonly hasReceipt: boolean,
) {}
}
class ExpensePolicy {
problems(expense: Expense): string[] {
const found: string[] = [];
if (expense.amount > 25 && !expense.hasReceipt) {
found.push("receipt needed over 25");
}
if (expense.category === "meals" && expense.amount > 60) {
found.push("meals capped at 60");
}
return found;
}
}
function claims(): string[][] {
const lunch = new Expense("Dana", "meals", 80, false);
const wine = new Expense("Sam", "alcohol", 20, true);
return [
new ExpensePolicy().problems(lunch),
new ExpensePolicy().problems(wine),
];
}After
class Expense {
constructor(
readonly employee: string,
readonly category: string,
readonly amount: number,
readonly hasReceipt: boolean,
) {}
}
interface ExpenseRule {
breaks(expense: Expense): boolean;
message(): string;
}
class ReceiptRule implements ExpenseRule {
breaks(expense: Expense): boolean {
return expense.amount > 25 && !expense.hasReceipt;
}
message(): string {
return "receipt needed over 25";
}
}
class MealCapRule implements ExpenseRule {
breaks(expense: Expense): boolean {
return expense.category === "meals" && expense.amount > 60;
}
message(): string {
return "meals capped at 60";
}
}
class AlcoholRule implements ExpenseRule {
breaks(expense: Expense): boolean {
return expense.category === "alcohol";
}
message(): string {
return "alcohol not refunded";
}
}
class ExpensePolicy {
constructor(private readonly rules: ExpenseRule[]) {}
problems(expense: Expense): string[] {
const found: string[] = [];
for (const rule of this.rules) {
if (rule.breaks(expense)) {
found.push(rule.message());
}
}
return found;
}
}
function claims(): string[][] {
const lunch = new Expense("Dana", "meals", 80, false);
const wine = new Expense("Sam", "alcohol", 20, true);
const rules: ExpenseRule[] = [];
rules.push(new ReceiptRule());
rules.push(new MealCapRule());
rules.push(new AlcoholRule());
const policy = new ExpensePolicy(rules);
return [policy.problems(lunch), policy.problems(wine)];
}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 claims 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 its list of problems comes back empty. 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 comes back with 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 an interface with two methods, where breaks checks a claim and message says what to report. Each rule class will declare that it implements this interface, so the compiler checks both methods for us. 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 implements Expense Rule. 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 return the same lists.
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 an array of rules can replace both of them. The loop asks each rule through the interface, so it doesn't know which rules exist. The array, 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 array moves out, and Expense Policy takes its rules through the constructor. Claims now registers the rules, which means it pushes each rule onto the array it gives the policy. Both claims go through that one policy, and they return the same two lists as before.
Payoff
Step 11: Alcohol, as a new class Payoff
Now the alcohol rule comes back. It's a new class, Alcohol Rule, that implements Expense Rule with the same two methods as the others. Claims registers it with one added line, and no existing line changes. The wine claim now returns 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 array. 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 implements the Expense Rule interface. 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 an interface and a class per rule. You'll spot the need by a method that grows one more if block with every feature request. Express in Node works this way, because each app use call registers one more middleware, and the router code never changes.