Low-Level Design (Object-Oriented Design) Module 4 – Design principles: SOLID and beyond
Open/Closed Principle (OCP)
The Open/Closed Principle in practice: add a vehicle type without editing tested code, choose closed sets or interfaces, and know when a seam is premature.
What you will learn
- Explain "open for extension, closed for modification" with a worked example
- Add a new variant through a seam instead of editing switch statements
- Choose between an interface and a closed set with exhaustive matching
- Decide when a new seam is worth adding and when it is premature
Before you start
On this page
Most new requirements are new variants of something the program already does: another vehicle type, another discount, another payment method. The Open/Closed Principle (OCP) asks for a design where a new variant is new code that you add, not old code that you edit. This lesson builds that on a parking lot, measures what it saves, and then looks at the cases where it is the wrong tool.
Open for extension, closed for modification
Bertrand Meyer stated the principle in his book Object-Oriented Software Construction, first published in 1988: modules should be both open and closed. Open means you can still extend a module, for instance by giving it more operations or more fields. Closed means other modules can already depend on it: its interface is settled, and it could be compiled and shipped as a library. The two sound contradictory, and with the techniques of the time they were. Meyer’s answer was inheritance: a closed class stays as it is, and a new class extends it.
Robert C. Martin restated it for classes: it should be possible to add to what a class does without editing the class. His means are abstractions. The code that holds the policy depends on an interface, and each variant is a new implementation of it. He calls plugin systems, programs that other people extend without touching their code, the strongest form of the idea.
In daily work, “closed” means that the code that already works, and its tests, stay untouched when a variant is added. “Open” means there is a place where the new variant plugs in. Such a place is called a seam in this track.
A switch that every new kind must edit
Here is a small parking program in the most direct style. Every function that depends on the kind of vehicle tests for each kind:
fees/switch/v1/main.py
from receipt import receipt
for vehicle, hours in [("bike", 3), ("car", 2), ("truck", 1)]:
print(receipt(vehicle, hours)) Output
Two-wheeler small 3 h 30 Car medium 2 h 50 Truck large 1 h 60
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py
fees/switch/v1/fees.py
def fee(vehicle, hours):
"""What a stay of `hours` whole hours (at least 1) costs, in whole currency units."""
if vehicle == "bike":
return 10 * hours
if vehicle == "car":
return 30 + 20 * (hours - 1)
if vehicle == "truck":
return 60 * hours
raise ValueError(f"unknown vehicle type: {vehicle}") fees/switch/v1/spots.py
def spot_size(vehicle):
"""Which size of parking spot a vehicle needs."""
if vehicle == "bike":
return "small"
if vehicle == "car":
return "medium"
if vehicle == "truck":
return "large"
raise ValueError(f"unknown vehicle type: {vehicle}") fees/switch/v1/receipt.py
from fees import fee
from spots import spot_size
LABELS = {"bike": "Two-wheeler", "car": "Car", "truck": "Truck"}
def receipt(vehicle, hours):
return f"{LABELS[vehicle]:<12} {spot_size(vehicle):<8} {hours} h {fee(vehicle, hours):>4}" Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Nothing is wrong with this code today. The trouble starts when the lot adds electric cars: fees.py, spots.py and
receipt.py each hold their own list of vehicle kinds, and each list must grow. Meyer described the same trap and
named the rule that avoids it, the Single Choice principle:
Whenever a software system must support a set of alternatives, one and only one module in the system should know their exhaustive list.
A seam: a registry of vehicle types
The second design gives every kind of vehicle a class with the three things the lot needs, a label, a spot size and
a fee(hours) method. vehicles.py describes that shape as a typing.Protocol and keeps a registry, and
receipt.py asks the registry for a kind and never names one:
fees/plugins/v1/main.py
import standard
from receipt import receipt
from vehicles import Registry
registry = Registry()
standard.install(registry)
for vehicle, hours in [("bike", 3), ("car", 2), ("truck", 1)]:
print(receipt(registry, vehicle, hours)) Output
Two-wheeler small 3 h 30 Car medium 2 h 50 Truck large 1 h 60
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py
fees/plugins/v1/vehicles.py
"""What the parking lot needs from any kind of vehicle, and the registry that holds the kinds.
Closed for modification: adding a kind of vehicle never edits this file.
"""
from typing import Protocol
class VehicleType(Protocol):
label: str
spot: str
def fee(self, hours: int) -> int:
"""What a stay of `hours` whole hours (at least 1) costs, in whole currency units."""
...
class Registry:
def __init__(self):
self._types: dict[str, VehicleType] = {}
def add(self, name: str, vehicle_type: VehicleType) -> None:
if name in self._types:
raise ValueError(f"{name!r} is registered already")
self._types[name] = vehicle_type
def get(self, name: str) -> VehicleType:
if name not in self._types:
raise ValueError(f"unknown vehicle type: {name}")
return self._types[name] fees/plugins/v1/standard.py
"""The kinds of vehicle the lot opened with."""
class Bike:
label, spot = "Two-wheeler", "small"
def fee(self, hours):
return 10 * hours
class Car:
label, spot = "Car", "medium"
def fee(self, hours):
return 30 + 20 * (hours - 1)
class Truck:
label, spot = "Truck", "large"
def fee(self, hours):
return 60 * hours
def install(registry):
registry.add("bike", Bike())
registry.add("car", Car())
registry.add("truck", Truck()) fees/plugins/v1/receipt.py
def receipt(registry, vehicle, hours):
kind = registry.get(vehicle)
return f"{kind.label:<12} {kind.spot:<8} {hours} h {kind.fee(hours):>4}" Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Three choices keep this design honest:
- The registry is an object, not a global dictionary.
main.pycreates it and hands it to whatever needs it, so no module reaches into shared state behind the others’ backs (the common-environment coupling of the first lesson in this module). - Each module of kinds installs itself.
standard.install(registry)adds bike, car and truck. A module for a new kind follows the same pattern. main.pyis the single choice. It is the one place that knows the complete list of kinds, because it decides which modules to install.
Where a new kind of vehicle goes in each design
Text description of the diagram
The diagram shows the parking-fee program twice, one design above the other.
- Switch statements: main.py calls receipt.py, which keeps a LABELS dictionary for bike, car and truck and calls fees.py and spots.py, which each test for bike, car and truck in an if statement. A new kind of vehicle has to be added to all three modules.
- A registry of vehicle types: vehicles.py and receipt.py know only the VehicleType protocol, a label, a spot size and a fee(hours) method. standard.py holds Bike, Car and Truck, and ev.py, the new file, holds ElectricCar; both follow the protocol. main.py calls install() on each of them to put their kinds in the registry. Adding electric cars added ev.py and one install() line to main.py, and changed nothing in vehicles.py, receipt.py or standard.py.
Measuring the change
Both designs then got electric cars: a “charging” spot and the car rate plus 5 an hour for the charger. Each version is saved in its own folder, and this program compares them:
"""What adding electric cars changed in each design, and whether both designs print the same receipts.
Each design has two versions in its folder: v1, and v2 with electric cars added.
"""
import contextlib
import difflib
import io
import runpy
import sys
from pathlib import Path
HERE = Path(__file__).parent
DESIGNS = {"switch": "switch statements", "plugins": "registry of vehicle types"}
def run(folder):
"""What folder/main.py prints, with the folder's modules imported fresh."""
for module in folder.glob("*.py"):
sys.modules.pop(module.stem, None)
sys.path.insert(0, str(folder))
try:
printed = io.StringIO()
with contextlib.redirect_stdout(printed):
runpy.run_path(str(folder / "main.py"), run_name="__main__")
return printed.getvalue()
finally:
sys.path.remove(str(folder))
def lines(path):
return path.read_text().splitlines() if path.exists() else []
sys.dont_write_bytecode = True
for version in ("v1", "v2"):
same = run(HERE / "switch" / version) == run(HERE / "plugins" / version)
print(f"{version}: both designs print the same receipts: {'yes' if same else 'NO'}")
print("\nAdding electric cars")
diffs = {}
for design, label in DESIGNS.items():
old, new = HERE / design / "v1", HERE / design / "v2"
edited, created, added, removed = [], [], 0, 0
diffs[design] = []
for name in sorted({p.name for p in old.glob("*.py")} | {p.name for p in new.glob("*.py")}):
before, after = lines(old / name), lines(new / name)
diff = list(difflib.unified_diff(before, after, f"v1/{name}", f"v2/{name}", n=0, lineterm=""))
if not diff:
continue
(edited if (old / name).exists() else created).append(name)
changed = [line for line in diff[2:] if not line.startswith("@@") and line[1:].strip()]
added += sum(line.startswith("+") for line in changed)
removed += sum(line.startswith("-") for line in changed)
if (old / name).exists():
diffs[design].append(diff)
edited_text, created_text = ", ".join(edited) or "nothing", ", ".join(created) or "nothing"
print(f" {label}: edited {edited_text}; new {created_text}; +{added} -{removed} lines")
print("\nThe edits to existing files:")
for design, label in DESIGNS.items():
print(f"\n[{label}]")
for diff in diffs[design]:
print("\n".join(diff)) Output
v1: both designs print the same receipts: yes
v2: both designs print the same receipts: yes
Adding electric cars
switch statements: edited fees.py, main.py, receipt.py, spots.py; new nothing; +6 -2 lines
registry of vehicle types: edited main.py; new ev.py; +12 -1 lines
The edits to existing files:
[switch statements]
--- v1/fees.py
+++ v2/fees.py
@@ -8,0 +9,2 @@
+ if vehicle == "ev":
+ return 30 + 20 * (hours - 1) + 5 * hours
--- v1/main.py
+++ v2/main.py
@@ -3 +3 @@
-for vehicle, hours in [("bike", 3), ("car", 2), ("truck", 1)]:
+for vehicle, hours in [("bike", 3), ("car", 2), ("truck", 1), ("ev", 2)]:
--- v1/receipt.py
+++ v2/receipt.py
@@ -4 +4 @@
-LABELS = {"bike": "Two-wheeler", "car": "Car", "truck": "Truck"}
+LABELS = {"bike": "Two-wheeler", "car": "Car", "truck": "Truck", "ev": "Electric car"}
--- v1/spots.py
+++ v2/spots.py
@@ -8,0 +9,2 @@
+ if vehicle == "ev":
+ return "charging"
[registry of vehicle types]
--- v1/main.py
+++ v2/main.py
@@ -0,0 +1 @@
+import ev
@@ -6,0 +8 @@
+ev.install(registry)
@@ -8 +10 @@
-for vehicle, hours in [("bike", 3), ("car", 2), ("truck", 1)]:
+for vehicle, hours in [("bike", 3), ("car", 2), ("truck", 1), ("ev", 2)]:
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure_change.py
Both designs added ("ev", 2) to the list of stays that main.py prints. Apart from that, the switch design
edited three modules that already worked, fees.py, spots.py and receipt.py, and each edit is a chance to break
bikes, cars or trucks. The registry design added one import and one install() call to main.py and put everything
else in a new file, ev.py. It even added more lines than the switch design did. OCP does not promise less code; it
promises that the tested code stays as it is, so the new code is the only place to look when something goes wrong.
Closed does not mean no edits anywhere
Something has to learn that electric cars exist. In this design it is main.py, the place where the program is
put together. Python can move even that out of the code: an installed package can advertise a plugin as an entry
point, declared in its pyproject.toml, and the program finds every plugin with
importlib.metadata.entry_points() without naming any of them.
Other kinds of seam
A registry is one seam among several. Choose the smallest that does the job:
- A function parameter: pass the varying behaviour in, as
sorted(items, key=...)does. Good for one varying step. - A protocol or an abstract base class: callers depend on the shape, implementations vary. Good when a variant has several operations, as the vehicle types do.
- A registry: a name-to-implementation table filled at start-up. Good when the variant is chosen by data, such as a vehicle code typed at the gate.
functools.singledispatch: a function whose implementation is chosen by the type of its first argument. New types register their own implementation withregister(), without editing the function.
Closed sets and open sets
Not every set of variants should be open. Payment states (pending, paid, refunded) are fixed by the business, and new
operations over them arrive all the time: describe a state, decide whether it can be refunded, choose the next
actions. Here the opposite design pays off. Keep the states a closed set, a union of small data classes, and write
each operation as one match that lists every state. typing.assert_never() marks the end of the list: a static
type checker reports any match that misses a state, and at run time the call raises an exception.
"""A closed set of payment states: every function over it lists every state, and assert_never() catches a gap.
Disputed was added to PaymentState later, and describe() was not updated for it.
"""
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Pending:
amount: int
@dataclass(frozen=True)
class Paid:
amount: int
reference: str
@dataclass(frozen=True)
class Refunded:
amount: int
@dataclass(frozen=True)
class Disputed:
amount: int
type PaymentState = Pending | Paid | Refunded | Disputed
def can_refund(state: PaymentState) -> bool:
match state:
case Paid():
return True
case Pending() | Refunded() | Disputed():
return False
case _:
assert_never(state)
def describe(state: PaymentState) -> str:
match state:
case Pending(amount):
return f"waiting for {amount}"
case Paid(amount, reference):
return f"paid {amount} ({reference})"
case Refunded(amount):
return f"refunded {amount}"
case _:
assert_never(state)
states: list[PaymentState] = [Pending(500), Paid(500, "TXN-81"), Refunded(500), Disputed(500)]
for state in states:
try:
print(f"{describe(state):<24} refundable: {can_refund(state)}")
except AssertionError as error:
print(f"AssertionError: {error}") Output
waiting for 500 refundable: False paid 500 (TXN-81) refundable: True refunded 500 refundable: False AssertionError: Expected code to be unreachable, but got: Disputed(amount=500)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 payment_states.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Disputed was added to the union, can_refund() was updated and describe() was not. A static type checker
reports that before the program runs. Without one, assert_never() still turns the gap into a clear
AssertionError instead of a function that silently returns None.
Philip Wadler called this tension the expression problem. Think of the variants as rows of a table and the
operations as columns. With an interface and one class per variant, adding a row is easy (a new class) and adding a
column is hard (every class needs a new method). With a closed set and match, adding a column is easy (a new
function) and adding a row is hard (every match needs a new case). Java offers the closed-set style through sealed
classes, final in Java 17, and pattern matching for switch, which the compiler checks for exhaustiveness, final in
Java 21.
| Criterion | A protocol, one class per variant | A closed set and match |
|---|---|---|
| New variant | A new class; no other code changes | A new case in every match |
| New operation | A new method in every class | A new function; no other code changes |
| Who adds variants | Anyone, even code you do not own | Only the owner of the set |
| Tool help | A type checker checks that each class has the methods | A type checker lists every match that misses a case |
| When to choose | Vehicle types, payment providers, notification channels | Payment states, order statuses, the cells of a board game |
When a seam is premature
A seam costs something: another file, another name, one more step between a caller and the code it runs. Martin Fowler’s note on yagni (“you aren’t gonna need it”) lists what a feature built for a presumed future costs. Building it is wasted effort if the need never comes, and even if it does come, the work delays features that are needed now and the extra code makes every later change harder until it pays off. He cites Jeremy Miller’s point that an unused extension point costs more than the effort spent on it, because it gets in the way. Fowler adds that yagni does not apply to work that makes code easier to change, such as refactoring; yagni only works because such work keeps code easy to change.
So this track’s rule of thumb is to add a seam when there is a reason in front of you:
- a second variant exists or has been asked for, as the electric cars were (a fake that your tests need counts);
- a rule changes often enough that each change risks the others (the reasons to change of the previous lesson);
- other people must extend your code without editing it, as plugin authors do.
Until then, a plain if is fine, and turning it into a seam later is a small, safe refactoring when the tests are
good.
In a design interview
Say where the seam goes even if you do not build it yet: “pricing is one function for now; when a second vehicle type with its own rule arrives, I will make fee a method of a VehicleType protocol”. Interviewers often follow up with exactly that new variant, and you can show the change touches one place.
Key takeaways
- OCP: add a variant by writing new code behind a seam, and leave the tested code closed. Meyer’s version used inheritance; Martin’s uses interfaces and plugins.
- Several switch statements over the same kinds break Meyer’s Single Choice principle; give the complete list to one module, such as the place where the program is put together.
- An open set of variants suits a protocol and a registry; a closed set with many operations suits
matchandassert_never(). - Add a seam when a second variant or a real reason to change appears, not for a future you imagine.
Exercise
Exercise · Easy · Python
Add two discount rules without editing Checkout
The shop's Checkout class in cart.py is finished and tested, and it is closed: it totals a cart and asks every rule it was given how much to take off. Each rule is an object with a method discount(lines, subtotal) that returns a number of cents. lines is a list of (name, unit price in cents, quantity) tuples.
Write the two rules the shop wants, in the classes waiting below Checkout:
BulkDiscount: 10% off every line of 10 or more units, rounded down to whole cents. Twelve pens at 250 cents each come to 3000 cents, so that line gets 300 off; nine pens get nothing.BigOrder: 500 cents off when the subtotal is 5000 cents or more.
A shop then turns rules on by passing them in, for example Checkout([BulkDiscount(), BigOrder()]). Do not edit Checkout: the last sample test compares its code with the original, so any change to its code fails, even a reworded docstring. The other tests check totals with each rule, with both, and right at the edges, 9 or 10 units and a subtotal of 4999 or 5000 cents.
Starter code · cart.py
class Checkout:
"""Totals a cart and lets every rule it was given take an amount off. Closed: do not edit this class."""
def __init__(self, rules=()):
self.rules = list(rules)
def total(self, lines):
"""lines: (name, unit price in cents, quantity) tuples."""
subtotal = sum(price * quantity for _, price, quantity in lines)
discount = sum(rule.discount(lines, subtotal) for rule in self.rules)
return max(subtotal - discount, 0)
# Your discount rules go below this line, as new classes.
class BulkDiscount:
"""10% off every line of 10 or more units, rounded down to whole cents."""
def discount(self, lines, subtotal):
return 0
class BigOrder:
"""500 cents off an order whose subtotal is 5000 cents or more."""
def discount(self, lines, subtotal):
return 0 The sample tests · test_cart.py
import ast
import hashlib
from pathlib import Path
import cart
from cart import BigOrder, BulkDiscount, Checkout
CHECKOUT_SHA256 = "d35f5b8bf2795ec1d622e43be09149d57b7f7303ebc6a183800d226ff8280b68"
def test_no_rules():
"""without rules the total is the subtotal"""
assert Checkout().total([("pens", 250, 12), ("notebook", 900, 1)]) == 3900
def test_bulk_discount():
"""takes 10% off lines of 10 or more units, rounded down"""
checkout = Checkout([BulkDiscount()])
assert checkout.total([("pens", 250, 12), ("notebook", 900, 1)]) == 3600
assert checkout.total([("paper", 99, 11)]) == 981
assert checkout.total([("rulers", 120, 10)]) == 1080
assert checkout.total([("pens", 250, 9)]) == 2250
def test_big_order():
"""takes 500 off a subtotal of 5000 or more"""
checkout = Checkout([BigOrder()])
assert checkout.total([("stapler", 5000, 1)]) == 4500
assert checkout.total([("stapler", 4999, 1)]) == 4999
def test_both_rules():
"""applies both rules to the same cart"""
checkout = Checkout([BulkDiscount(), BigOrder()])
assert checkout.total([("paper", 99, 11), ("stapler", 4500, 1)]) == 4981
def test_checkout_is_closed():
"""leaves the Checkout class exactly as it was"""
tree = ast.parse(Path(cart.__file__).read_text(encoding="utf-8"))
checkout = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == "Checkout")
digest = hashlib.sha256(ast.dump(checkout).encode()).hexdigest()
assert digest == CHECKOUT_SHA256, "Checkout's code has changed: add discounts as new rule classes instead" A hint
Each rule only looks at what it is given. BulkDiscount can add up price * quantity // 10 over the lines whose quantity is at least 10 (// rounds down), and BigOrder needs only subtotal.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- Object-Oriented Software Construction, second edition (section 3.3, five principles) (Bertrand Meyer)
- Some contributions (Bertrand Meyer)
- The Open Closed Principle (Robert C. Martin, The Clean Code Blog)
- The Principles of OOD (archived copy) (Robert C. Martin, via the Internet Archive)
- The Expression Problem (Philip Wadler, The University of Edinburgh)
- typing: Protocol and assert_never (Python Software Foundation)
- functools.singledispatch (Python Software Foundation)
- JEP 409: Sealed Classes (OpenJDK)
- JEP 441: Pattern Matching for switch (OpenJDK)
- Entry points specification (Python Packaging Authority)
- Yagni (Martin Fowler)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress