Low-level designCore11 min

Open/closed principle

Add new behavior by adding code, not by editing code that already works. A class that is open for extension and closed for modification takes a new case without being opened up and retested.

Watch it evolve1 / 10
The problem

A sign-up form checks passwords

PasswordPolicy checks each new password against the rules the site turns on, and lists every rule it breaks.

80
Start
Stage 1
Stage 2
Stage 3
Final

1 · The situation

The rules a new password must pass

A ticket site's sign-up form checks each new password before it creates the account. The site turns on the rules it wants, and PasswordPolicy reports every rule a password breaks, so the form can list all of them at once instead of one per attempt.

The first version names the rules in an enum, Rule, and handles them in two private methods of PasswordPolicy: passes() decides whether a password meets a rule, and message() says what to fix. problems() asks both for each rule that's turned on. Python writes the helpers in snake case with a leading underscore, _passes() and _message(), as private methods are by convention.

The first versionJava
import java.util.ArrayList;
import java.util.List;

enum Rule { MIN_LENGTH, HAS_DIGIT, HAS_SYMBOL }

class PasswordPolicy {
    private final List<Rule> rules;

    PasswordPolicy(List<Rule> rules) {
        this.rules = List.copyOf(rules);
    }

    List<String> problems(String password) {
        List<String> out = new ArrayList<>();
        for (Rule rule : rules) {
            if (!passes(rule, password)) out.add(message(rule));
        }
        return out;
    }

    private boolean passes(Rule rule, String password) {
        return switch (rule) {
            case MIN_LENGTH -> password.length() >= 10;
            case HAS_DIGIT -> password.chars().anyMatch(Character::isDigit);
            case HAS_SYMBOL -> password.chars().anyMatch(c -> !Character.isLetterOrDigit(c));
        };
    }

    private String message(Rule rule) {
        return switch (rule) {
            case MIN_LENGTH -> "use at least 10 characters";
            case HAS_DIGIT -> "add a digit";
            case HAS_SYMBOL -> "add a symbol";
            default -> "choose another password";
        };
    }
}

public class Main {
    static void report(PasswordPolicy policy, String password) {
        List<String> problems = policy.problems(password);
        String verdict = problems.isEmpty() ? "accepted" : String.join("; ", problems);
        System.out.println(password + ": " + verdict);
    }

    public static void main(String[] args) {
        PasswordPolicy policy = new PasswordPolicy(
                List.of(Rule.MIN_LENGTH, Rule.HAS_DIGIT, Rule.HAS_SYMBOL));
        for (String password : List.of("sunflower", "Password123!", "blue-otter-42")) {
            report(policy, password);
        }
    }
}
*«enum»RuleMIN_LENGTHHAS_DIGITHAS_SYMBOLPasswordPolicy− rules: List<Rule>~ problems(String): List<String>− passes(Rule, String): boolean− message(Rule): String

The class diagram of this version, drawn from its code: the policy holds a list of enum values.

The program checks three passwords against the three rules:

sunflower: use at least 10 characters; add a digit; add a symbol
Password123!: accepted
blue-otter-42: accepted

2 · The change

A fourth rule, and a message that says nothing

The security team asks for a fourth rule. The site keeps a short list of passwords that are too common to allow, and Password123! is on it, even though it's long enough and has a digit and a symbol.

In this design one rule lives in several places. The obvious edit adds a value to the enum, a case to passes() with the list it checks, and turns the rule on in main:

The fourth rule, added the obvious wayJava
enum Rule { MIN_LENGTH, HAS_DIGIT, HAS_SYMBOL, NOT_COMMON }

class PasswordPolicy {
    private static final Set<String> COMMON =
            Set.of("password123!", "qwerty12345!");
    private final List<Rule> rules;

    PasswordPolicy(List<Rule> rules) {
        this.rules = List.copyOf(rules);
    }

    List<String> problems(String password) {
        List<String> out = new ArrayList<>();
        for (Rule rule : rules) {
            if (!passes(rule, password)) out.add(message(rule));
        }
        return out;
    }

    private boolean passes(Rule rule, String password) {
        return switch (rule) {
            case MIN_LENGTH -> password.length() >= 10;
            case HAS_DIGIT -> password.chars().anyMatch(Character::isDigit);
            case HAS_SYMBOL -> password.chars().anyMatch(c -> !Character.isLetterOrDigit(c));
            case NOT_COMMON -> !COMMON.contains(password.toLowerCase());
        };
    }

    private String message(Rule rule) {
        return switch (rule) {
            case MIN_LENGTH -> "use at least 10 characters";
            case HAS_DIGIT -> "add a digit";
            case HAS_SYMBOL -> "add a symbol";
            default -> "choose another password";
        };
    }
}

It compiles and runs. Look at the second line:

sunflower: use at least 10 characters; add a digit; add a symbol
Password123!: choose another password
blue-otter-42: accepted

The common password is rejected, but the message doesn't say why.

The edit missed message(). Its default branch, written to be safe, takes the new rule and returns a generic message, so the user is told to choose another password without being told what's wrong with this one. Whether a missed place is caught depends on the place and the language:

Place to edit

If it's missed, in Java or C++

If it's missed, in JavaScript or Python

The Rule enum

There's no rule to turn on

There's no rule to turn on

passes(), with no default

Java: a compile error, because passes() is a switch expression that no longer covers every rule. C++: a warning, an error with -Werror

An error at run time, the first time the rule is checked

message(), with a default

Nothing: the default branch takes the new rule

Nothing: the default branch takes the new rule

The bug is small, but the cost is in the design:

  • Every new rule edits a class that works. PasswordPolicy already passes its tests, and each new rule reopens it, next to the rules that already work.
  • The parts of one rule are far apart. A rule's test and its message sit in two switches, and only the enum ties them together.
  • A safe default hides a gap. In C++, GCC's warning about a missing enum case is off for a switch that has a default, and in Java the compiler holds an enum switch to every value only when it's a switch expression.

The practice checks flag this design: Rule is switched on in two places.

3 · The fix

Make each rule an object that answers for itself

Start again from the first version. Each switch asks a rule one of two questions: do you pass, and what should the user fix. Move both answers into the rule:

PasswordRule declares the two questions, passes(password) and message(). In JavaScript it's a documented shape any object can have, and in Python a Protocol.

Java
interface PasswordRule {
    boolean passes(String password);
    String message();
}
*«interface»PasswordRule+ passes(String): boolean+ message(): StringMinLength− length: int+ passes(String): boolean+ message(): StringHasDigit+ passes(String): boolean+ message(): StringHasSymbol+ passes(String): boolean+ message(): StringPasswordPolicy− rules: List<PasswordRule>~ problems(String): List<String>

After the first three steps the program prints exactly what the first version printed. After the fourth, the common password gets a message that says what's wrong:

sunflower: use at least 10 characters; add a digit; add a symbol
Password123!: this password is too common
blue-otter-42: accepted
Enum and switches
3
A class per rule
0

Places in existing code the fourth rule edits. Before: the enum, passes() and message(). After: none. Both versions also add the rule to the list in main.

4 · The principle

Open for extension, closed for modification

The open/closed principle says a module should be open for extension, so new behavior can be added to it, and closed for modification, so adding that behavior doesn't mean editing its source. PasswordPolicy is closed: it was written and tested once, and new rules don't touch it. It's open: any class that implements PasswordRule extends what it checks.

*«interface»PasswordRule+ passes(String): boolean+ message(): StringMinLength− length: int+ passes(String): boolean+ message(): StringHasDigit+ passes(String): boolean+ message(): StringHasSymbol+ passes(String): boolean+ message(): StringNotCommon− common: Set<String>+ passes(String): boolean+ message(): StringPasswordPolicy− rules: List<PasswordRule>~ problems(String): List<String>

The closed part and the open part: PasswordPolicy depends only on PasswordRule, and each rule is a class of its own.

The mechanism is the one from Polymorphism: the policy calls passes() on each rule and never asks which class it has. Any way of plugging in code can make a module open:

Way to extend

How a new case arrives

In this story

An interface

A new class that implements it

NotCommon implements PasswordRule

A function passed in

A new function, often a lambda

A rule as a test function plus its message

A base class

A subclass that overrides one method

A CharacterRule base that scans the characters; HasDigit fills in the test for one

Data

A new value in a constructor, table or file

new MinLength(12)

The principle was first stated in terms of inheritance: a finished class stays as it is, and a subclass adds to it. The usual reading today goes through interfaces: the interface stays fixed, and new implementations plug in behind it. Either way, tested code stays untouched.

Closure is a choice. No class is closed against every change. PasswordPolicy is closed against new rules, the change that keeps coming. To stop at the first broken rule, or to report problems in another order, you'd still edit it. You choose which changes to close against, and the best guide is the changes you've already seen.

5 · When breaking it is fine

New cases get cheap, new questions get dear

The fix has costs:

  • More types. One enum became an interface and four classes.
  • The rules are spread out. To read every rule you open four classes instead of two switches.

A new question also reaches every class. Say the form wants a short hint for each rule, shown before the user types. With classes, PasswordRule gains a method and every rule class implements it. With the enum, it was one new method with one switch.

Design

A new rule

A new question for every rule

An enum and switches

The enum and every switch change

One new method with one switch

An interface and a class per rule

One new class

The interface and every class change

Neither design is open to both kinds of change; the trade-off is known as the expression problem. Choose by which change you expect more often.

Keep the switch when:

  • The cases are fixed. Days of the week or the sides of a trade won't grow. A switch is the clearest code, and an exhaustive one, such as a Java switch expression or a switch over a sealed type, makes the compiler check every case.
  • New questions come more often than new cases. Then the switch design is the one that's closed against the change you see.

And keep it while no change has come yet. A plug-in point for a change that never comes costs types and reading time for nothing. Write the simple version, and refactor when the first new case arrives: it shows which way the code grows.

6 · Getting it right

Close against the change that keeps coming

  • Don't give an enum switch a safety default. If you keep a switch, let the compiler check it. A default turns a forgotten case from a compile error into a quiet wrong answer.
  • Keep the parts of a case together. A rule's test and message now live in one class, so they can't drift apart.
  • Make values parameters, not classes. MinLength(10) and MinLength(12) are one class with two settings.
  • Choose the cases in one place. Something still decides which rules are on. Here it's main, the only code that names the rule classes, so the rest of the program never changes when a rule is added.

Each language has a plug-in point, and each checks a switch differently:

Language

The usual plug-in point

Checks that a switch covers every case

Java

An interface, or a functional interface filled by a lambda

Yes, for a switch expression or a sealed type, unless it has a default

C++

An abstract class with pure virtual functions, or std::function

A warning for an enum switch without a default, made an error by -Werror

JavaScript

Any object with the right methods, or a plain function

No

Python

A Protocol, or a plain function

Not at run time; a type checker can, with assert_never in a final case _

7 · Look-alikes

One principle, several ways to name it

Several ideas sit close to this one. What tells them apart is the question each one asks.

Idea

The question it asks

Open/closed

Can a new case be added without editing code that works?

Polymorphism

Which method runs is decided by the object: the mechanism most open designs use

Strategy

How does one object switch between ways of doing one job? A pattern that applies the principle

Single responsibility

Does this class have one reason to change?

Dependency inversion

Does the policy depend on an abstraction rather than on the details?

YAGNI

Is this extension point needed yet?

Strategy is this principle applied to one job: an object holds one of several ways and uses whichever it's given. PasswordPolicy holds many rules and runs all of them, so it's the same idea with a list instead of one field. And open/closed and YAGNI pull against each other: one says to leave room for change, the other says not to build for a change you haven't seen.

8 · In practice

A snack machine with three switches

In the vending-machine practice problem, Kai's draft for the Northgate snack machine keeps its state in an enum, Status, and switches on it in insertCoin(), select() and cancel(). A new state, such as out of order, means editing all three. The checks report it as "Status is switched on in 3 places".

They look for the same enum switched on in two or more methods. A single switch is left alone, since it's often the simplest right answer. Fix the vending machine so a new state is a new class, with the checks reading your code.

Check yourself

4 questions

1. After the fix, the site wants passwords of at least 12 characters instead of 10. What changes?
2. After the fix, the form needs a short hint for every rule, shown before the user types. What does that cost?
3. Why did the default branch in message() make the first design worse?
4. Which code is most worth refactoring for the open/closed principle?

Takeaways

Remember this

  • Open for extension, closed for modification: a new case arrives as new code, not as an edit to code that works.
  • An enum switched on in several places spreads each case across the code; a class per case keeps it in one place.
  • A default on an enum switch hides the cases you forgot; let the compiler check every case.
  • No class is closed against every change: close against the change that keeps coming, once you've seen it.
  • Classes make new cases cheap and new operations dear, and switches do the reverse.