I'll Be Backend
Principles · Java

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 Java 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

record Expense(String employee, String category, int amount,
               boolean hasReceipt) {}

class ExpensePolicy {
    List<String> problems(Expense expense) {
        List<String> found = new ArrayList<>();
        if (expense.amount() > 25 && !expense.hasReceipt()) {
            found.add("receipt needed over 25");
        }
        if (expense.category().equals("meals") && expense.amount() > 60) {
            found.add("meals capped at 60");
        }
        return found;
    }
}

class Claims {
    public static void main(String[] args) {
        Expense lunch = new Expense("Dana", "meals", 80, false);
        Expense wine = new Expense("Sam", "alcohol", 20, true);
        System.out.println(new ExpensePolicy().problems(lunch));
        System.out.println(new ExpensePolicy().problems(wine));
    }
}

After

record Expense(String employee, String category, int amount,
               boolean hasReceipt) {}

interface ExpenseRule {
    boolean breaks(Expense expense);
    String message();
}

class ReceiptRule implements ExpenseRule {
    public boolean breaks(Expense expense) {
        return expense.amount() > 25 && !expense.hasReceipt();
    }

    public String message() {
        return "receipt needed over 25";
    }
}

class MealCapRule implements ExpenseRule {
    public boolean breaks(Expense expense) {
        return expense.category().equals("meals") && expense.amount() > 60;
    }

    public String message() {
        return "meals capped at 60";
    }
}

class AlcoholRule implements ExpenseRule {
    public boolean breaks(Expense expense) {
        return expense.category().equals("alcohol");
    }

    public String message() {
        return "alcohol not refunded";
    }
}

class ExpensePolicy {
    private final List<ExpenseRule> rules;

    ExpensePolicy(List<ExpenseRule> rules) {
        this.rules = List.copyOf(rules);
    }

    List<String> problems(Expense expense) {
        List<String> found = new ArrayList<>();
        for (ExpenseRule rule : rules) {
            if (rule.breaks(expense)) {
                found.add(rule.message());
            }
        }
        return found;
    }
}

class Claims {
    public static void main(String[] args) {
        Expense lunch = new Expense("Dana", "meals", 80, false);
        Expense wine = new Expense("Sam", "alcohol", 20, true);
        List<ExpenseRule> rules = new ArrayList<>();
        rules.add(new ReceiptRule());
        rules.add(new MealCapRule());
        rules.add(new AlcoholRule());
        ExpensePolicy policy = new ExpensePolicy(rules);
        System.out.println(policy.problems(lunch));
        System.out.println(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. Claims 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 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 a list of rules can replace both of them. The loop asks each rule through the interface, 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 the constructor. Claims 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, and it implements Expense Rule like the other two. Claims 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 behind 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. Java's Service Loader works this way, so JDBC picks up a new database driver from its jar, and Driver Manager never changes.