Open-Closed Principle
Learn the Open-Closed Principle (OCP): classes should be open for extension but closed for modification
Open-Closed Principle (OCP)
Software entities (classes, modules, functions) should be open for extension, but closed for modification.
The Open-Closed Principle is the second SOLID principle. It means you should be able to add new functionality without changing existing code. Instead of modifying a class to support new behavior, you extend it through inheritance, composition, or plug-in architectures.
The Problem: Modification Cascades
When code isn't closed for modification, every new feature requires changing existing code. This leads to bugs, regressions, and merge conflicts.
BEFORE: OCP Violation
Imagine a payment processing system where every new payment method requires modifying the processor:
from typing import Any
class PaymentProcessor:
def process_payment(self, method: str, amount: float,
details: dict[str, Any]) -> str:
if method == "credit_card":
return self._process_credit_card(amount, details)
elif method == "paypal":
return self._process_paypal(amount, details)
elif method == "bank_transfer":
return self._process_bank_transfer(amount, details)
else:
raise ValueError(f"Unknown payment method: {method}")
def _process_credit_card(self, amount: float,
details: dict[str, Any]) -> str:
card_number = details.get("card_number")
cvv = details.get("cvv")
if not card_number or not cvv:
raise ValueError("Missing credit card details")
if len(str(card_number)) != 16:
raise ValueError("Invalid card number")
print(f"Charging ${amount:.2f} to card ending in {str(card_number)[-4:]}")
return f"cc_txn_{hash(card_number)}"
def _process_paypal(self, amount: float,
details: dict[str, Any]) -> str:
email = details.get("email")
password = details.get("password")
if not email or not password:
raise ValueError("Missing PayPal credentials")
print(f"Processing ${amount:.2f} via PayPal for {email}")
return f"pp_txn_{hash(email)}"
def _process_bank_transfer(self, amount: float,
details: dict[str, Any]) -> str:
routing = details.get("routing_number")
account = details.get("account_number")
if not routing or not account:
raise ValueError("Missing bank account details")
print(f"Transferring ${amount:.2f} to account {str(account)[-4:]}")
return f"bt_txn_{hash(account)}"Every time a new payment method is needed (e.g., "crypto", "apple_pay"), you must modify the PaymentProcessor class. This violates OCP — the class is not closed for modification.
Problems with this design:
| Problem | Impact |
|---|---|
| Modification required | Every new payment method changes existing code |
| Risk of regression | Adding a method could break existing ones |
| Large conditional chains | Hard to read, test, and maintain |
| Tight coupling | All methods depend on one class |
| Violates SRP too | Processor knows about all payment types |
AFTER: OCP-Compliant Refactoring
Use an abstract base class and polymorphic dispatch:
from abc import ABC, abstractmethod
from typing import Any, Optional
class PaymentMethod(ABC):
"""Abstract base — open for extension via new subclasses."""
@abstractmethod
def charge(self, amount: float, details: dict[str, Any]) -> str:
pass
@abstractmethod
def validate(self, details: dict[str, Any]) -> None:
pass
class CreditCard(PaymentMethod):
def validate(self, details: dict[str, Any]) -> None:
card = details.get("card_number")
cvv = details.get("cvv")
if not card or not cvv:
raise ValueError("Missing credit card details")
if len(str(card)) != 16:
raise ValueError("Invalid card number")
def charge(self, amount: float, details: dict[str, Any]) -> str:
self.validate(details)
card = str(details["card_number"])
print(f"Charging ${amount:.2f} to card ending in {card[-4:]}")
return f"cc_txn_{hash(card)}"
class PayPal(PaymentMethod):
def validate(self, details: dict[str, Any]) -> None:
if not details.get("email") or not details.get("password"):
raise ValueError("Missing PayPal credentials")
def charge(self, amount: float, details: dict[str, Any]) -> str:
self.validate(details)
email = details["email"]
print(f"Processing ${amount:.2f} via PayPal for {email}")
return f"pp_txn_{hash(email)}"
class BankTransfer(PaymentMethod):
def validate(self, details: dict[str, Any]) -> None:
if not details.get("routing_number") or not details.get("account_number"):
raise ValueError("Missing bank account details")
def charge(self, amount: float, details: dict[str, Any]) -> str:
self.validate(details)
account = str(details["account_number"])
print(f"Transferring ${amount:.2f} to account {account[-4:]}")
return f"bt_txn_{hash(account)}"
class PaymentProcessor:
"""Closed for modification — open for extension."""
def __init__(self):
self._methods: dict[str, PaymentMethod] = {}
def register_method(self, name: str, method: PaymentMethod) -> None:
self._methods[name] = method
def process(self, method_name: str, amount: float,
details: dict[str, Any]) -> str:
method = self._methods.get(method_name)
if not method:
raise ValueError(f"Unknown payment method: {method_name}")
return method.charge(amount, details)The PaymentProcessor class is closed for modification — its code never changes when new payment methods are added. It's open for extension — new methods are added by creating new PaymentMethod subclasses and registering them.
# Usage — adding crypto doesn't touch existing code!
processor = PaymentProcessor()
processor.register_method("credit_card", CreditCard())
processor.register_method("paypal", PayPal())
processor.register_method("bank_transfer", BankTransfer())
result = processor.process("credit_card", 150.00, {
"card_number": 1234567812345678,
"cvv": "123"
})
print(result)
# Adding a new method without modifying existing classes:
class CryptoPayment(PaymentMethod):
def validate(self, details: dict[str, Any]) -> None:
if not details.get("wallet_address"):
raise ValueError("Missing wallet address")
def charge(self, amount: float, details: dict[str, Any]) -> str:
self.validate(details)
wallet = details["wallet_address"]
print(f"Sending ${amount:.2f} in crypto to {wallet[:8]}...")
return f"crypto_txn_{hash(wallet)}"
processor.register_method("crypto", CryptoPayment())New payment methods are now added by creating a subclass of PaymentMethod and registering it with the processor. The PaymentProcessor and existing payment classes are never modified.
Strategy Pattern: The OCP Workhorse
The example above uses the Strategy pattern, which is the most common way to achieve OCP. Here's a generic template:
from abc import ABC, abstractmethod
from typing import Any
class Strategy(ABC):
@abstractmethod
def execute(self, context: Any) -> Any:
pass
class Context:
def __init__(self):
self._strategies: dict[str, Strategy] = {}
def register(self, name: str, strategy: Strategy) -> None:
self._strategies[name] = strategy
def run(self, name: str, input_data: Any) -> Any:
strategy = self._strategies.get(name)
if not strategy:
raise ValueError(f"Unknown strategy: {name}")
return strategy.execute(input_data)
class ConcreteStrategyA(Strategy):
def execute(self, context: Any) -> Any:
return f"Strategy A processed: {context}"
class ConcreteStrategyB(Strategy):
def execute(self, context: Any) -> Any:
return f"Strategy B processed: {context}"More Examples of OCP
Example 2: Discount Calculator
BEFORE: Modification-based
class DiscountCalculator:
def calculate(self, customer_type: str, amount: float) -> float:
if customer_type == "regular":
return amount * 0.05
elif customer_type == "silver":
return amount * 0.10
elif customer_type == "gold":
return amount * 0.20
elif customer_type == "platinum":
return amount * 0.30
else:
return 0.0AFTER: Extension-based
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, amount: float) -> float:
pass
class RegularDiscount(DiscountStrategy):
def apply(self, amount: float) -> float:
return amount * 0.05
class SilverDiscount(DiscountStrategy):
def apply(self, amount: float) -> float:
return amount * 0.10
class GoldDiscount(DiscountStrategy):
def apply(self, amount: float) -> float:
return amount * 0.20
class PlatinumDiscount(DiscountStrategy):
def apply(self, amount: float) -> float:
return amount * 0.30
class DiscountEngine:
def __init__(self):
self._discounts: dict[str, DiscountStrategy] = {}
def register(self, customer_type: str, strategy: DiscountStrategy) -> None:
self._discounts[customer_type] = strategy
def calculate(self, customer_type: str, amount: float) -> float:
strategy = self._discounts.get(customer_type)
if not strategy:
return 0.0
return strategy.apply(amount)
engine = DiscountEngine()
engine.register("regular", RegularDiscount())
engine.register("silver", SilverDiscount())
engine.register("gold", GoldDiscount())
engine.register("platinum", PlatinumDiscount())
# New type added later — no existing code changed:
class VIPDiscount(DiscountStrategy):
def apply(self, amount: float) -> float:
return amount * 0.40
engine.register("vip", VIPDiscount())Example 3: Notification System
BEFORE
class Notifier:
def send(self, message: str, channel: str) -> None:
if channel == "email":
self._send_email(message)
elif channel == "sms":
self._send_sms(message)
elif channel == "slack":
self._send_slack(message)
def _send_email(self, msg): ...
def _send_sms(self, msg): ...
def _send_slack(self, msg): ...AFTER
from abc import ABC, abstractmethod
class NotificationChannel(ABC):
@abstractmethod
def send(self, message: str, recipient: str) -> None:
pass
class EmailChannel(NotificationChannel):
def send(self, message: str, recipient: str) -> None:
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["To"] = recipient
msg["Subject"] = "Notification"
msg.set_content(message)
with smtplib.SMTP("smtp.example.com") as server:
server.send_message(msg)
class SMSChannel(NotificationChannel):
def send(self, message: str, recipient: str) -> None:
print(f"Sending SMS to {recipient}: {message}")
# Integration with Twilio or other SMS provider
class SlackChannel(NotificationChannel):
def send(self, message: str, recipient: str) -> None:
print(f"Sending Slack message to {recipient}: {message}")
# Integration with Slack API
class Notifier:
def __init__(self):
self.channels: dict[str, NotificationChannel] = {}
def register_channel(self, name: str, channel: NotificationChannel) -> None:
self.channels[name] = channel
def send(self, channel_name: str, message: str, recipient: str) -> None:
channel = self.channels.get(channel_name)
if not channel:
raise ValueError(f"Unknown channel: {channel_name}")
channel.send(message, recipient)
# Adding push notifications — no modification needed:
class PushChannel(NotificationChannel):
def send(self, message: str, recipient: str) -> None:
print(f"Sending push notification to device {recipient}: {message}")
notifier = Notifier()
notifier.register_channel("push", PushChannel())OCP and the Strategy Pattern
OCP with the Template Method Pattern
Sometimes you want an algorithm skeleton where subclasses fill in the details:
from abc import ABC, abstractmethod
from pathlib import Path
class DataExporter(ABC):
"""Template Method pattern — skeleton is closed, steps are open."""
def export(self, data: list[dict], output_path: str) -> Path:
validated = self._validate(data)
transformed = self._transform(validated)
content = self._serialize(transformed)
path = Path(output_path)
path.write_text(content)
print(f"Exported {len(data)} records to {path}")
return path
def _validate(self, data: list[dict]) -> list[dict]:
if not data:
raise ValueError("No data to export")
return data
def _transform(self, data: list[dict]) -> list[dict]:
return data # Default: no transformation
@abstractmethod
def _serialize(self, data: list[dict]) -> str:
pass
class CSVExporter(DataExporter):
def _serialize(self, data: list[dict]) -> str:
import csv
import io
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
return output.getvalue()
class JSONExporter(DataExporter):
def _serialize(self, data: list[dict]) -> str:
import json
return json.dumps(data, indent=2)
class XMLExporter(DataExporter):
def _transform(self, data: list[dict]) -> list[dict]:
# XML needs special character escaping
result = []
for item in data:
escaped = {k: str(v).replace("&", "&").replace("<", "<")
for k, v in item.items()}
result.append(escaped)
return result
def _serialize(self, data: list[dict]) -> str:
lines = ["<?xml version='1.0' encoding='UTF-8'?>", "<records>"]
for item in data:
lines.append(" <record>")
for key, value in item.items():
lines.append(f" <{key}>{value}</{key}>")
lines.append(" </record>")
lines.append("</records>")
return "\n".join(lines)OCP with Composition (Decorator Pattern)
from abc import ABC, abstractmethod
class DataSource(ABC):
@abstractmethod
def read(self) -> str:
pass
@abstractmethod
def write(self, data: str) -> None:
pass
class FileDataSource(DataSource):
def __init__(self, path: str):
self.path = path
def read(self) -> str:
from pathlib import Path
return Path(self.path).read_text()
def write(self, data: str) -> None:
from pathlib import Path
Path(self.path).write_text(data)
class EncryptionDecorator(DataSource):
def __init__(self, source: DataSource):
self._source = source
def read(self) -> str:
encrypted = self._source.read()
return self._decrypt(encrypted)
def write(self, data: str) -> None:
encrypted = self._encrypt(data)
self._source.write(encrypted)
def _encrypt(self, data: str) -> str:
import base64
return base64.b64encode(data.encode()).decode()
def _decrypt(self, data: str) -> str:
import base64
return base64.b64decode(data.encode()).decode()
class CompressionDecorator(DataSource):
def __init__(self, source: DataSource):
self._source = source
def read(self) -> str:
compressed = self._source.read()
return self._decompress(compressed)
def write(self, data: str) -> None:
compressed = self._compress(data)
self._source.write(compressed)
def _compress(self, data: str) -> str:
import gzip
import base64
return base64.b64encode(gzip.compress(data.encode())).decode()
def _decompress(self, data: str) -> str:
import gzip
import base64
return gzip.decompress(base64.b64decode(data.encode())).decode()
# Compose at runtime — OCP-compliant
source = CompressionDecorator(EncryptionDecorator(FileDataSource("data.txt")))
source.write("Hello, OCP!")
print(source.read()) # Original data backThe Decorator pattern is a great way to extend behavior without modifying existing classes. Each decorator adds one responsibility and is itself open for further decoration.
OCP Violations: Warning Signs
| Warning Sign | Description |
|---|---|
if/elif/else chains on type | New types require new branches |
match/case on type codes | Same problem as if/elif |
isinstance() checks | Polymorphism should replace these |
| Large switch statements | Usually indicate missing abstraction |
| Feature flags littered in code | Consider strategy pattern instead |
type field with conditional logic | Extract to polymorphic classes |
Comparing Approaches
| Aspect | Without OCP (if/elif) | With OCP (Strategy) |
|---|---|---|
| Adding new behavior | Modify existing class | Create new subclass |
| Risk of regression | High | Low |
| Test surface | Entire class | Single new class |
| Code reuse | Low (duplicated conditionals) | High (shared abstractions) |
| Dependency direction | Concrete depends on concrete | Both depend on abstraction |
| Readability | Grows linearly worse | Stays constant |
OCP doesn't mean you should never change existing code. Bug fixes, refactoring for clarity, and performance optimizations are valid modifications. OCP targets behavioral extension — adding new features should not require rewriting existing features.
Practice Exercises
-
Identify the OCP violation in this code and refactor it:
pythonclass Logger: def log(self, message, output_type="console"): if output_type == "console": print(message) elif output_type == "file": with open("log.txt", "a") as f: f.write(message + "\n") elif output_type == "db": # save to database... pass -
Design a notification system that follows OCP. Users should be able to add new notification channels (email, SMS, push, Slack, Teams) without modifying existing code.
-
A
ShippingCalculatorcurrently handles"ground","air", and"sea"shipping. Apply OCP so new shipping methods can be added without modifying the calculator. -
Implement a
ValidationRuleabstract class and aValidatorthat runs registered rules. Add rules forEmailRule,RequiredRule,MinLengthRule, andRangeRule. -
What is the Strategy pattern and how does it help achieve OCP? Draw the UML class diagram.
-
Refactor this code using OCP:
pythonclass ReportGenerator: def generate(self, data, format): if format == "pdf": # generate PDF elif format == "html": # generate HTML elif format == "xlsx": # generate Excel -
Explain the difference between "closed for modification" and "frozen/unchangeable." When is it acceptable to modify existing code?
-
Create a plugin-style architecture using OCP where a
TextProcessoraccepts registered plugins that can transform text (e.g.,UpperCasePlugin,LowerCasePlugin,RemovePunctuationPlugin).
Summary
- OCP: Classes should be open for extension, closed for modification
- Goal: Add new features without changing existing, tested code
- Primary technique: Abstract base classes + polymorphic dispatch (Strategy pattern)
- Other patterns: Template Method, Decorator, Observer all support OCP
- Benefit: Reduced regression risk, easier testing, clear extension points
- Signs of violation: Long if/elif chains, type-code conditionals,
isinstancechecks
OCP transforms your code from a brittle monolith into an extensible framework. When done right, adding new features feels like plugging in a component, not rewriting existing code.