The Open/Closed Principle (OCP) says classes should be open for extension but closed for modification. You should be able to add a feature by writing new code, not by editing code that already works and is already tested. Think of a game console: you can't change its internals, but you can add new games.
Violation#
Every new customer type means editing this method, and every edit risks breaking the existing branches:
class DiscountCalculator:
def calculate_discount(self, customer_type: str, amount: float) -> float:
if customer_type == "Regular":
return amount * 0.05
elif customer_type == "Premium":
return amount * 0.10
elif customer_type == "VIP":
return amount * 0.20
return 0
calc = DiscountCalculator()
print(calc.calculate_discount("VIP", 100)) # 20.0
Fix: an abstraction plus one class per variation#
Give each customer type its own class behind a shared interface:
from abc import ABC, abstractmethod
class Discount(ABC):
@abstractmethod
def calculate(self, amount: float) -> float:
pass
class RegularDiscount(Discount):
def calculate(self, amount):
return amount * 0.05
class PremiumDiscount(Discount):
def calculate(self, amount):
return amount * 0.10
class VIPDiscount(Discount):
def calculate(self, amount):
return amount * 0.20
class DiscountCalculator:
def calculate(self, discount: Discount, amount: float) -> float:
return discount.calculate(amount) # never needs to change
calc = DiscountCalculator()
print(calc.calculate(RegularDiscount(), 100)) # 5.0
print(calc.calculate(VIPDiscount(), 100)) # 20.0
Adding a StudentDiscount is now a new class. DiscountCalculator stays untouched.
Why this works: dynamic dispatch#
Which calculate runs is decided by the actual object at runtime, not by the type hint on the variable or parameter:
d: Discount # type hint: Discount
d = RegularDiscount() # actual object: RegularDiscount
print(d.calculate(100)) # runs RegularDiscount's version (5.0)
d = VIPDiscount() # same name, different object
print(d.calculate(100)) # runs VIPDiscount's version (20.0)
- Type hint: what's declared (
Discount). It tells you and the type checker which methods you are allowed to call. - Actual object type: what was created (
VIPDiscount). It decides which implementation runs.
The variable is like a TV remote: the buttons stay the same, but what happens depends on which TV it's pointed at.
Other ways to achieve OCP#
The common idea: move the part that varies out of the core class.
1. Strategy (composition)#
Pass behavior in as an object that implements an interface.
from typing import Protocol
class DiscountStrategy(Protocol):
def apply(self, amount: float) -> float: ...
class RegularDiscount:
def apply(self, amount: float) -> float:
return amount * 0.05
class Order:
def __init__(self, discount: DiscountStrategy):
self._discount = discount
def discount_for(self, amount: float) -> float:
return self._discount.apply(amount)
print(Order(RegularDiscount()).discount_for(100)) # 5.0
2. Configuration-driven#
Keep the values in data so a new type is a config change, not a code change.
{ "Regular": 0.05, "Premium": 0.10, "VIP": 0.20 }
class DiscountCalculator:
def __init__(self, rates: dict[str, float]):
self._rates = rates # loaded from the config file
def calculate(self, customer_type: str, amount: float) -> float:
return amount * self._rates.get(customer_type, 0.0)
rates = {"Regular": 0.05, "Premium": 0.10, "VIP": 0.20}
print(DiscountCalculator(rates).calculate("VIP", 100)) # 20.0
3. Plugin architecture#
Load new features as separately packaged plugins that implement a known interface (e.g. report types loaded at startup) so the core never changes.
4. Decorator#
Wrap an object to add behavior.
coffee = SimpleCoffee()
coffee = MilkDecorator(coffee)
coffee = SugarDecorator(coffee) # SimpleCoffee never modified
5. Observer / events#
Add behavior by registering listeners rather than editing the source.
from typing import Callable
class Button:
def __init__(self):
self._listeners: list[Callable[[], None]] = []
def add_listener(self, listener: Callable[[], None]) -> None:
self._listeners.append(listener)
def click(self) -> None:
for listener in self._listeners:
listener()
button = Button()
button.add_listener(lambda: print("Saved")) # new behavior, Button unchanged
button.click()
6. Functions as parameters#
In Python, functions are objects, so you can pass behavior in directly:
from typing import Callable
def calculate(amount: float, operation: Callable[[float], float]) -> float:
return operation(amount)
print(calculate(100, lambda x: x * 0.15)) # new behavior: 15.0
Trade-offs#
OCP is a tool, not a goal:
- Don't add extension points for variations that may never happen; a simple
ifis fine until a second or third case appears. - Each abstraction adds indirection. Apply OCP where change is frequent (pricing, payment methods, notification channels).
Key takeaways#
- Add features by adding code, not by editing working code.
- Replace growing
if/elifchains on a type with polymorphism. - Runtime dispatch on the actual object type is what makes this work.
- Strategy, decorator, observer, config and plugins are all ways to externalize variation.