Open/Closed Principle (OCP)
The Open/Closed Principle states:
"Software entities should be open for extension, but closed for modification."
You should be able to add new behavior without changing existing code.
This sounds contradictory. How can you add behavior without modification? The answer is abstraction.
The Problem
Consider a payment processor:
class PaymentProcessor {
process(payment: Payment): void {
if (payment.type === 'credit_card') {
this.processCreditCard(payment);
} else if (payment.type === 'paypal') {
this.processPayPal(payment);
} else if (payment.type === 'bank_transfer') {
this.processBankTransfer(payment);
}
// What happens when we add crypto? Apple Pay? Wire transfer?
}
private processCreditCard(payment: Payment) { /* ... */ }
private processPayPal(payment: Payment) { /* ... */ }
private processBankTransfer(payment: Payment) { /* ... */ }
}
Every new payment method requires:
- Modifying the
processmethod to add a new case - Adding a new private method
- Re-testing the entire class
The class is open for modification (you keep changing it) but closed for extension (you can't add behavior without editing).
The Solution: Abstraction
Define an interface. Let each payment method implement it:
interface PaymentMethod {
process(payment: Payment): PaymentResult;
}
class CreditCardPayment implements PaymentMethod {
process(payment: Payment): PaymentResult {
// Credit card specific logic
}
}
class PayPalPayment implements PaymentMethod {
process(payment: Payment): PaymentResult {
// PayPal specific logic
}
}
class BankTransferPayment implements PaymentMethod {
process(payment: Payment): PaymentResult {
// Bank transfer specific logic
}
}
class PaymentProcessor {
constructor(private methods: Map<string, PaymentMethod>) {}
process(payment: Payment): PaymentResult {
const method = this.methods.get(payment.type);
if (!method) throw new UnknownPaymentTypeError(payment.type);
return method.process(payment);
}
}
Now adding crypto is easy:
class CryptoPayment implements PaymentMethod {
process(payment: Payment): PaymentResult {
// Crypto specific logic
}
}
// Register it
paymentMethods.set('crypto', new CryptoPayment());
No changes to PaymentProcessor or existing payment methods.
The system is:
- Open for extension: Add new payment methods by adding new classes
- Closed for modification: Existing code doesn't change
Techniques for OCP
Strategy Pattern
Replace conditional logic with strategies:
// Before: closed for extension
function calculateShipping(order: Order): number {
switch (order.shippingMethod) {
case 'standard': return 5.00;
case 'express': return 15.00;
case 'overnight': return 25.00;
}
}
// After: open for extension
interface ShippingCalculator {
calculate(order: Order): number;
}
const shippingCalculators: Record<string, ShippingCalculator> = {
standard: { calculate: () => 5.00 },
express: { calculate: () => 15.00 },
overnight: { calculate: () => 25.00 },
};
function calculateShipping(order: Order): number {
const calculator = shippingCalculators[order.shippingMethod];
return calculator.calculate(order);
}
Template Method
Define the skeleton in a base class, let subclasses fill in details:
abstract class OrderProcessor {
process(order: Order): void {
this.validate(order);
this.calculateTotals(order);
this.processPayment(order); // Template method
this.fulfill(order);
}
protected abstract processPayment(order: Order): void;
}
class OnlineOrderProcessor extends OrderProcessor {
protected processPayment(order: Order): void {
// Online payment logic
}
}
class InStoreOrderProcessor extends OrderProcessor {
protected processPayment(order: Order): void {
// In-store payment logic
}
}
Plugin Architecture
Load extensions dynamically:
// Core system
class NotificationSystem {
private handlers: NotificationHandler[] = [];
register(handler: NotificationHandler): void {
this.handlers.push(handler);
}
notify(event: Event): void {
this.handlers.forEach(h => h.handle(event));
}
}
// Plugins extend without modifying core
notificationSystem.register(new EmailNotificationHandler());
notificationSystem.register(new SlackNotificationHandler());
notificationSystem.register(new SMSNotificationHandler());
When OCP Applies
OCP is most valuable when:
- You expect new variants (payment types, file formats, notification channels)
- Changes are frequent in that area
- The abstraction is clear and stable
OCP might be overkill when:
- The code rarely changes
- There are only 2-3 variants that are unlikely to grow
- The abstraction is forced or unclear
The YAGNI Tension
YAGNI (You Aren't Gonna Need It) says don't build for hypothetical futures.
OCP says design for extension.
How to resolve? Apply OCP when you feel the pain of modification. If you've added the third payment method and it required modifying the same switch statement three times, refactor to OCP. Don't do it preemptively for the first two.
Real-World Example: Event Handlers
Before (closed for extension):
class EventProcessor {
handle(event: Event): void {
if (event.type === 'user_created') {
this.sendWelcomeEmail(event);
this.initializeProfile(event);
} else if (event.type === 'order_placed') {
this.notifyWarehouse(event);
this.sendConfirmation(event);
} else if (event.type === 'payment_failed') {
this.alertAdmin(event);
this.notifyCustomer(event);
}
}
}
After (open for extension):
interface EventHandler {
eventType: string;
handle(event: Event): void;
}
class EventProcessor {
private handlers: Map<string, EventHandler[]> = new Map();
register(handler: EventHandler): void {
const existing = this.handlers.get(handler.eventType) || [];
this.handlers.set(handler.eventType, [...existing, handler]);
}
handle(event: Event): void {
const handlers = this.handlers.get(event.type) || [];
handlers.forEach(h => h.handle(event));
}
}
// Adding new event handling is just adding handlers
processor.register(new WelcomeEmailHandler());
processor.register(new ProfileInitHandler());
processor.register(new WarehouseNotifier());
Key insight: OCP is about using abstraction to enable extension without modification. When you find yourself repeatedly modifying the same switch statement or if-else chain, extract an interface and let new behavior be new implementations. The existing code stays stable; new features are new code.