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
declare(strict_types=1);
final readonly class Expense
{
public function __construct(
public string $employee,
public string $category,
public int $amount,
public bool $hasReceipt,
) {}
}
final class ExpensePolicy
{
/** @return list<string> */
public function problems(Expense $expense): array
{
$found = [];
if ($expense->amount > 25 && !$expense->hasReceipt) {
$found[] = 'receipt needed over 25';
}
if ($expense->category === 'meals' && $expense->amount > 60) {
$found[] = 'meals capped at 60';
}
return $found;
}
}
$lunch = new Expense('Dana', 'meals', 80, false);
$wine = new Expense('Sam', 'alcohol', 20, true);
echo implode(', ', (new ExpensePolicy())->problems($lunch)) . PHP_EOL;
echo implode(', ', (new ExpensePolicy())->problems($wine)) . PHP_EOL;After
declare(strict_types=1);
final readonly class Expense
{
public function __construct(
public string $employee,
public string $category,
public int $amount,
public bool $hasReceipt,
) {}
}
interface ExpenseRule
{
public function breaks(Expense $expense): bool;
public function message(): string;
}
final class ReceiptRule implements ExpenseRule
{
public function breaks(Expense $expense): bool
{
return $expense->amount > 25 && !$expense->hasReceipt;
}
public function message(): string
{
return 'receipt needed over 25';
}
}
final class MealCapRule implements ExpenseRule
{
public function breaks(Expense $expense): bool
{
return $expense->category === 'meals' && $expense->amount > 60;
}
public function message(): string
{
return 'meals capped at 60';
}
}
final class AlcoholRule implements ExpenseRule
{
public function breaks(Expense $expense): bool
{
return $expense->category === 'alcohol';
}
public function message(): string
{
return 'alcohol not refunded';
}
}
final class ExpensePolicy
{
/** @param list<ExpenseRule> $rules */
public function __construct(
private readonly array $rules,
) {}
/** @return list<string> */
public function problems(Expense $expense): array
{
$found = [];
foreach ($this->rules as $rule) {
if ($rule->breaks($expense)) {
$found[] = $rule->message();
}
}
return $found;
}
}
$lunch = new Expense('Dana', 'meals', 80, false);
$wine = new Expense('Sam', 'alcohol', 20, true);
$rules = [];
$rules[] = new ReceiptRule();
$rules[] = new MealCapRule();
$rules[] = new AlcoholRule();
$policy = new ExpensePolicy($rules);
echo implode(', ', $policy->problems($lunch)) . PHP_EOL;
echo implode(', ', $policy->problems($wine)) . PHP_EOL;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 script at the bottom of claims dot php 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 echo prints an empty line. 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 line.
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. 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 the interface. 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 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. A PHP array carries no element type, so the doc comment tells PHPStan this is a list of Expense Rules. The script now registers the rules, which means it adds each rule to the array 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, and it implements Expense Rule like the other two. The script 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, every rule sits behind the Expense Rule interface, one class per rule. 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. The Slim framework works this way with PSR-15 middleware, so a new middleware is one more add call, and the app itself never changes.