bigonotes
CS ConceptsSOLID Principles

Open/Closed Principle

How to add features by adding code instead of editing it, using polymorphism, strategies and other extension points.

3 min readLast updated SOLID Principles

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 if is 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/elif chains 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.

Found a mistake? Tell me on LinkedIn ↗

Further reading#