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.
A sign-up form checks passwords
PasswordPolicy checks each new password against the rules the site turns on, and lists every rule it breaks.
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.
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);
}
}
}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:
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 | There's no rule to turn on | There's no rule to turn on |
| Java: a compile error, because | An error at run time, the first time the rule is checked |
| Nothing: the | Nothing: the |
The bug is small, but the cost is in the design:
- Every new rule edits a class that works.
PasswordPolicyalready 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.
interface PasswordRule {
boolean passes(String password);
String message();
}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
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.
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 |
|
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 |
Data | A new value in a constructor, table or file |
|
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. Adefaultturns 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)andMinLength(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 |
C++ | An abstract class with pure virtual functions, or | A warning for an enum switch without a |
JavaScript | Any object with the right methods, or a plain function | No |
Python | A | Not at run time; a type checker can, with |
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
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
defaulton 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.