Open/closed in depth
Module 03 showed open/closed with one promotion. This module looks at the choices behind it: which kind of extension point to use, the one place that always stays open, why a design is only closed against the changes it planned for, and when an extension point isn't worth adding.
1. The idea
Bertrand Meyer's rule, as it's used today: software should be open for extension and closed for modification. In practice that means: when a new kind of thing arrives (a new vehicle, a new payment method, a new report format), you add new code at an extension point rather than editing code that already works and is tested.
| Extension point | How new behaviour arrives | Good for |
|---|---|---|
| An interface (strategy) | A new class implementing it | New behaviour with its own logic: an EV charging fee |
| A registry or map of handlers | Register a new handler under a key | Choosing behaviour by type or name at run time |
| Data or configuration | A new row or setting, no code at all | Changes that are only numbers: a new hourly rate |
| Events or hooks | A new listener | Extra reactions to something that already happens |
Something stays open
Some code must still choose which rule to use and wire it in (here, one line in the setup). That's fine: keep all such choices in one place, the composition root (Module 03e).
You're only closed against what you planned for
A fee interface makes new vehicle types cheap. A change it didn't plan for (a fee that needs data the interface doesn't pass) still edits the interface and its callers.
Plug in, don't inherit
Extending by subclassing works, but subclasses depend on their parent's internals; a change to the base class can break them all. An interface plus composition keeps each rule independent.
2. How it works
The parking lot adds electric-vehicle bays that charge for parking and for the energy used. Follow the change in two designs.
3. Try it yourself
Where does each change land?
Four requests arrive over a year. Pick a design and see which requests edit tested code (red), add new code (green), or change only the setup or data (grey).
4. In code
Parking fees as rules behind an interface. Cars and motorbikes reuse one hourly rule with different numbers; electric bays add a new rule that wraps the hourly fee. FeeCalculator is never edited; the setup registers each rule.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class Ticket:
vehicle: str
minutes: int
kwh: int = 0 # energy drawn while parked (electric cars only)
def money(cents: int) -> str:
return f"{cents // 100}.{cents % 100:02d}"
def started_hours(minutes: int) -> int:
return -(-minutes // 60) # 150 minutes -> 3 started hours
# The extension point: every way of charging is a FeeRule.
class FeeRule(ABC):
@abstractmethod
def fee(self, t: Ticket) -> int: ...
class HourlyFee(FeeRule):
def __init__(self, cents_per_hour: int) -> None:
self.rate = cents_per_hour
def fee(self, t: Ticket) -> int:
return started_hours(t.minutes) * self.rate
# Added later, without editing anything above: parking fee plus the energy used.
class ChargingFee(FeeRule):
def __init__(self, parking: FeeRule, cents_per_kwh: int) -> None:
self.parking, self.per_kwh = parking, cents_per_kwh
def fee(self, t: Ticket) -> int:
return self.parking.fee(t) + t.kwh * self.per_kwh
# Closed: this class never changes when a new vehicle type or rule arrives.
class FeeCalculator:
def __init__(self, rules: dict[str, FeeRule]) -> None:
self.rules = rules
def fee(self, t: Ticket) -> int:
if t.vehicle not in self.rules:
raise ValueError(f"no fee rule for {t.vehicle}")
return self.rules[t.vehicle].fee(t)
if __name__ == "__main__":
# The one place that is not closed: where rules are chosen and wired together.
rules: dict[str, FeeRule] = {"car": HourlyFee(200), "motorbike": HourlyFee(100)}
calc = FeeCalculator(rules)
print(f"car, 150 min: {money(calc.fee(Ticket('car', 150)))}")
print(f"motorbike, 150 min: {money(calc.fee(Ticket('motorbike', 150)))}")
rules["ev"] = ChargingFee(HourlyFee(200), 30) # new behaviour: one new class, one new line here
print(f"ev, 150 min, 20 kWh: {money(calc.fee(Ticket('ev', 150, 20)))}")
interface Ticket { vehicle: string; minutes: number; kwh: number; } // kwh: energy drawn while parked
const money = (c: number): string => `${Math.floor(c / 100)}.${String(c % 100).padStart(2, "0")}`;
const startedHours = (minutes: number): number => Math.ceil(minutes / 60); // 150 minutes -> 3 started hours
// The extension point: every way of charging is a FeeRule.
interface FeeRule { fee(t: Ticket): number; }
class HourlyFee implements FeeRule {
constructor(private centsPerHour: number) {}
fee(t: Ticket): number { return startedHours(t.minutes) * this.centsPerHour; }
}
// Added later, without editing anything above: parking fee plus the energy used.
class ChargingFee implements FeeRule {
constructor(private parking: FeeRule, private centsPerKwh: number) {}
fee(t: Ticket): number { return this.parking.fee(t) + t.kwh * this.centsPerKwh; }
}
// Closed: this class never changes when a new vehicle type or rule arrives.
class FeeCalculator {
constructor(private rules: Map<string, FeeRule>) {}
fee(t: Ticket): number {
const rule = this.rules.get(t.vehicle);
if (!rule) throw new Error(`no fee rule for ${t.vehicle}`);
return rule.fee(t);
}
}
// The one place that is not closed: where rules are chosen and wired together.
const rules = new Map<string, FeeRule>([["car", new HourlyFee(200)], ["motorbike", new HourlyFee(100)]]);
const calc = new FeeCalculator(rules);
console.log(`car, 150 min: ${money(calc.fee({ vehicle: "car", minutes: 150, kwh: 0 }))}`);
console.log(`motorbike, 150 min: ${money(calc.fee({ vehicle: "motorbike", minutes: 150, kwh: 0 }))}`);
rules.set("ev", new ChargingFee(new HourlyFee(200), 30)); // new behaviour: one new class, one new line here
console.log(`ev, 150 min, 20 kWh: ${money(calc.fee({ vehicle: "ev", minutes: 150, kwh: 20 }))}`);import java.util.HashMap;
import java.util.Map;
record Ticket(String vehicle, int minutes, int kwh) {} // kwh: energy drawn while parked
// The extension point: every way of charging is a FeeRule.
interface FeeRule {
int fee(Ticket t);
}
class HourlyFee implements FeeRule {
private final int centsPerHour;
HourlyFee(int centsPerHour) { this.centsPerHour = centsPerHour; }
public int fee(Ticket t) { return ((t.minutes() + 59) / 60) * centsPerHour; } // started hours
}
// Added later, without editing anything above: parking fee plus the energy used.
class ChargingFee implements FeeRule {
private final FeeRule parking;
private final int centsPerKwh;
ChargingFee(FeeRule parking, int centsPerKwh) { this.parking = parking; this.centsPerKwh = centsPerKwh; }
public int fee(Ticket t) { return parking.fee(t) + t.kwh() * centsPerKwh; }
}
// Closed: this class never changes when a new vehicle type or rule arrives.
class FeeCalculator {
private final Map<String, FeeRule> rules;
FeeCalculator(Map<String, FeeRule> rules) { this.rules = rules; }
int fee(Ticket t) {
FeeRule rule = rules.get(t.vehicle());
if (rule == null) throw new IllegalArgumentException("no fee rule for " + t.vehicle());
return rule.fee(t);
}
}
public class Main {
static String money(int c) { return (c / 100) + "." + String.format("%02d", c % 100); }
public static void main(String[] args) {
// The one place that is not closed: where rules are chosen and wired together.
Map<String, FeeRule> rules = new HashMap<>();
rules.put("car", new HourlyFee(200));
rules.put("motorbike", new HourlyFee(100));
FeeCalculator calc = new FeeCalculator(rules);
System.out.println("car, 150 min: " + money(calc.fee(new Ticket("car", 150, 0))));
System.out.println("motorbike, 150 min: " + money(calc.fee(new Ticket("motorbike", 150, 0))));
rules.put("ev", new ChargingFee(new HourlyFee(200), 30)); // new behaviour: one new class, one new line here
System.out.println("ev, 150 min, 20 kWh: " + money(calc.fee(new Ticket("ev", 150, 20))));
}
}Output (the same in all three languages)
car, 150 min: 6.00 motorbike, 150 min: 3.00 ev, 150 min, 20 kWh: 12.00
5. In the interview
- Find the axis of change in the prompt. "We'll add more vehicle types", "more payment methods later": that's where an extension point pays off. Say it out loud: "fees vary by vehicle, so fee rules go behind an interface".
- Name what stays open. "Adding EV means one new class and one line where rules are registered." Interviewers like hearing that you know where the seam is.
- Prefer data when the change is only numbers. A new rate shouldn't need a new class; a new way of charging should.
- Don't add extension points nobody asked for. A common rule: wait until the second or third variant before extracting the interface, unless the prompt says variants are coming.
6. Remember it
How this hook works: what “Add a plug, don't rewire the wall” means
What the slogan means, phrase by phrase
- “Add a plug”
- New behaviour is a new class, handler or row.
- “don't rewire the wall”
- Code that works and is tested isn't edited for every new variant.
Why this picture fits: A wall socket: new lamps plug in; nobody rewires the house.
The story → the concept
| The house | The code |
|---|---|
| The socket | The FeeRule interface |
| A new lamp | ChargingFee for electric cars |
| Plugging it in | One line registering the rule |
| The wiring in the wall | FeeCalculator, untouched |
Watch it on an example
- Motorbikes: register HourlyFee(100). No new code.
- EV charging: one new class, one registration line.
- A change the socket didn't plan for (a fee needing new data) still means editing the interface.
The trap it saves you from
Speculative extension points: interfaces for variants nobody has asked for, making simple code hard to follow.
It still works when…
Even in data-driven designs: a rates table is an extension point too, open to new numbers and closed to new kinds of rule.
7. Common mistakes
8. Practice exercise (25 minutes)
- Find the axis (5 min). In the vending machine problem, list three things the prompt suggests will vary.
- Add an extension point (10 min). Put one of them behind an interface; add a second implementation.
- Check what's still open (5 min). List every line you'd edit to add a third implementation. Is it only the setup?
- Data or class? (5 min). Which of your three variations could be a config value instead of a class?
9. Self-check quiz
Adding motorbikes needs a new case in FeeCalculator's switch. Which principle is strained?
With fee rules behind an interface, what still changes when EV bays arrive?
The car rate goes from 2.00 to 2.50 an hour. Best design for that kind of change?
When is adding an extension point premature?
10. Go deeper
| Resource | Time | Why |
|---|---|---|
| Module 03c: Liskov substitution in depth | Next | Every plugged-in rule must keep the interface's promises. |
| Module 04: Design patterns | 30 min | Strategy, decorator and observer are ready-made extension points. |
| P06: Parking lot | 45 min | The full problem this module's example comes from. |