Lesson 33 of 54

SOLID Principles in Practice

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:

Typescript
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:

  1. Modifying the process method to add a new case
  2. Adding a new private method
  3. 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:

Typescript
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:

Typescript
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:

Typescript
// 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:

Typescript
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:

Typescript
// 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):

Typescript
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):

Typescript
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.