Open/Closed Principle
What Problem Does It Solve?
The Open/Closed Principle (OCP) solves the problem of modifying existing, working code every time you need to add a new feature. Each modification risks introducing bugs into code that was previously stable. OCP says software entities should be open for extension but closed for modification.
Think of a power outlet with adapters. The wall outlet is closed for modification — you don't rewire it when you get a new device. Instead, you extend it by plugging in an adapter. Your code should work the same way: instead of changing existing modules, add new ones that plug into a stable interface.
How to Identify When to Use It
- You keep changing the same file when adding new features
- A function or class has a long
if/elifchain that grows with each new variant - You see
switchstatements or dict-based dispatch that needs updating for new types - Adding a new feature requires touching multiple unrelated files
- You're afraid to modify a "core" file because it might break everything
Questions to ask yourself:
- Can I add this new feature by writing a new class (no changes to existing ones)?
- Does my design use polymorphism or dependency injection to handle variation?
- If I need a new behavior next month, can I just "plug in" a new component?
Red flags: A single file that changes with every feature request, strategy patterns implemented as if-else chains, business logic in giant switch statements, "configuration" files that contain executable logic.
How to Apply It
- Identify the varying behavior in your system (what changes when new features are added?)
- Extract that behavior behind an interface or abstract class
- Make existing code depend on the abstraction, not concrete types
- Add new implementations of the interface without touching existing code
from abc import ABC, abstractmethod
# ❌ Before — violates OCP: adding a new format requires changing this class
class ReportGenerator:
def generate(self, data: dict, format: str) -> str:
if format == "json":
import json
return json.dumps(data)
elif format == "xml":
# manual XML building
return f"<root><name>{data['name']}</name></root>"
# Adding "csv" or "yaml" means modifying this file!
# ✅ After — open for extension, closed for modification
class ReportFormatter(ABC):
@abstractmethod
def format(self, data: dict) -> str:
pass
class JSONFormatter(ReportFormatter):
def format(self, data: dict) -> str:
import json
return json.dumps(data)
class XMLFormatter(ReportFormatter):
def format(self, data: dict) -> str:
return f"<root><name>{data['name']}</name></root>"
# New formatters are NEW files, not modifications to existing code:
class CSVFormatter(ReportFormatter):
def format(self, data: dict) -> str:
return "\n".join(f"{k},{v}" for k, v in data.items())
class YAMLFormatter(ReportFormatter):
def format(self, data: dict) -> str:
return "\n".join(f"{k}: {v}" for k, v in data.items())
class ReportGenerator:
def __init__(self, formatters: dict[str, ReportFormatter]):
self._formatters = formatters
def generate(self, data: dict, format: str) -> str:
if format not in self._formatters:
raise ValueError(f"Unknown format: {format}")
return self._formatters[format].format(data)
# Usage — register formatters at startup
gen = ReportGenerator({
"json": JSONFormatter(),
"xml": XMLFormatter(),
"csv": CSVFormatter(),
"yaml": YAMLFormatter(),
})
print(gen.generate({"name": "Alice"}, "csv"))Real-World Example
A discount calculator for an e-commerce site where new discount types need to be added frequently without touching the core calculation engine.
from abc import ABC, abstractmethod
from decimal import Decimal
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, price: Decimal) -> Decimal:
pass
class NoDiscount(DiscountStrategy):
def apply(self, price: Decimal) -> Decimal:
return price
class PercentageDiscount(DiscountStrategy):
def __init__(self, percent: Decimal):
self._percent = percent
def apply(self, price: Decimal) -> Decimal:
return price * (Decimal(1) - self._percent / 100)
class BuyOneGetOne(DiscountStrategy):
def apply(self, price: Decimal) -> Decimal:
return price / 2 # effectively 50% off on one item
class LoyaltyDiscount(DiscountStrategy):
def __init__(self, years: int):
self._years = years
def apply(self, price: Decimal) -> Decimal:
discount = min(Decimal(self._years) * Decimal(5), 40)
return price * (Decimal(1) - discount / 100)
class PriceCalculator:
def __init__(self, strategy: DiscountStrategy):
self._strategy = strategy
def calculate(self, base_price: Decimal) -> Decimal:
return self._strategy.apply(base_price)
# Adding a "FlashSaleDiscount" tomorrow?
# Just create a new class — PriceCalculator stays untouched.
class FlashSaleDiscount(DiscountStrategy):
def apply(self, price: Decimal) -> Decimal:
return price * Decimal(0.3) # 70% offThe PriceCalculator is closed for modification but open for extension — new discount strategies are new files, not edits to existing ones.
Common Mistakes / Pitfalls
- Premature abstraction: Don't abstract until you see the variation point appear. Following OCP too early can lead to unnecessary complexity. Wait for the second occurrence of a pattern.
- Too many small interfaces: Having
interface A,interface B,interface Cwhere each has one method and one implementation creates needless indirection. Refactor when the variation actually emerges. - Plug-in architecture overkill: Not every system needs a full plugin system. Sometimes a simple function parameter is enough OCP compliance.
- Ignoring OCP in tests: If adding a new feature requires rewriting all your tests, your design may not be as open/closed as you think. Tests should be extendable too.
- Abstracting in the wrong direction: Extract what varies, not what stays the same. The stable parts can remain concrete.
