Published on

Mastering the Open/Closed Principle in TypeScript with Real-World Examples

The Open/Closed Principle (OCP) is the second principle of SOLID and one of the most powerful tools in a software engineer's toolkit.

Definition:

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

Explanation: You should be able to add new functionality without altering existing code. Modifying existing code can introduce bugs and cause regressions. Instead, use abstractions like interfaces or inheritance to extend functionality.

❌ Bad Example β€” Violating OCP

Suppose you're building a payment processing module:

class PaymentProcessor {
  processPayment(method: string, amount: number) {
    if (method === 'paypal') {
      console.log(`Processing PayPal payment: $${amount}`);
    } else if (method === 'stripe') {
      console.log(`Processing Stripe payment: $${amount}`);
    } else if (method === 'bitcoin') {
      console.log(`Processing Bitcoin payment: $${amount}`);
    } else {
      throw new Error('Unsupported payment method');
    }
  }
}

🧨 Problems:

  • Every time a new payment method is introduced, you have to modify this class.
  • It violates OCP by being closed to extension and open to modification.
  • Harder to test and maintain.
  • Increases risk of bugs when changing core logic.

βœ… Good Example β€” Applying OCP with Strategy Pattern

We'll use interfaces and polymorphism to fix this:

interface PaymentMethod {
  process(amount: number): void;
}

class PayPalPayment implements PaymentMethod {
  process(amount: number) {
    console.log(`Processing PayPal payment: $${amount}`);
  }
}

class StripePayment implements PaymentMethod {
  process(amount: number) {
    console.log(`Processing Stripe payment: $${amount}`);
  }
}

class BitcoinPayment implements PaymentMethod {
  process(amount: number) {
    console.log(`Processing Bitcoin payment: $${amount}`);
  }
}

class PaymentProcessor {
  constructor(private method: PaymentMethod) {}

  pay(amount: number) {
    this.method.process(amount);
  }
}

Now you can add new payment methods without touching the PaymentProcessor class.

✨ Benefits of Good (OCP-Compliant) Code

βœ… BenefitπŸš€ Impact
Easier to extendAdd new functionality without touching existing logic
Safer changesReduced risk of breaking existing features
Better testabilityEach strategy (payment method) can be unit-tested in isolation
Higher maintainabilityClear separation of responsibilities
Enhanced readabilityCode is easier to reason about
Promotes team collaborationMultiple devs can work on new features without conflicts

🧠 How to Detect the Need for OCP

You might need to apply OCP when:

  • You have a class with growing if/else or switch statements.
  • You frequently need to modify a class to support new requirements.
  • Your code changes frequently due to new business rules or types.
  • There are many changes to the same class by different developers.
  • Your system is hard to test because logic is tightly coupled.

πŸ› οΈ Techniques to Apply the Open/Closed Principle

TechniqueDescription
Use Interfaces/Abstract ClassesAllow implementation to vary without changing usage
Strategy PatternEncapsulate different behaviors behind a common interface
Template Method PatternDefine a base algorithm with customizable steps
Factory PatternDelegate object creation to a factory to avoid hard-coded classes
Dependency InjectionInject behavior via constructor to enable easier substitution

🧩 Advanced Real-World Scenario

Problem: Notification System

You need to send notifications through multiple channels (Email, SMS, Push, WhatsApp). Initially, you write:

class NotificationService {
  send(channel: string, message: string) {
    if (channel === 'email') {
      // Send email
    } else if (channel === 'sms') {
      // Send SMS
    }
    // You get the idea...
  }
}

Refactored with OCP:

interface Notifier {
  send(message: string): void;
}

class EmailNotifier implements Notifier {
  send(message: string) {
    console.log(`Email: ${message}`);
  }
}

class SMSNotifier implements Notifier {
  send(message: string) {
    console.log(`SMS: ${message}`);
  }
}

class PushNotifier implements Notifier {
  send(message: string) {
    console.log(`Push: ${message}`);
  }
}

class NotificationService {
  constructor(private notifier: Notifier) {}

  notify(message: string) {
    this.notifier.send(message);
  }
}

Now you can add WhatsAppNotifier without touching any other class.

πŸ§ͺ Unit Testing Advantage

const mockNotifier: Notifier = {
  send: jest.fn(),
};

const service = new NotificationService(mockNotifier);
service.notify("Hello!");

expect(mockNotifier.send).toHaveBeenCalledWith("Hello!");

➑ Easy to mock and test behavior in isolation.

🧠 Pro Tips

  • Keep your abstractions focused on behavior, not data.
  • Don't abstract too earlyβ€”refactor when patterns emerge.
  • Combine with SRP and Dependency Injection for powerful architecture.

πŸ”„ Relationship Between OCP and SRP

🧠 Definitions Recap:

  • SRP (Single Responsibility Principle)

    A class should have only one reason to change.

  • OCP (Open/Closed Principle)

    Software entities should be open for extension, but closed for modification.

πŸ”— How SRP Supports OCP

To apply the Open/Closed Principle, we must first separate concerns β€” and that's exactly what SRP does.

πŸ‘‡ Here's how:

SRP Helps You…Which Enables OCP by…
Splitting large classes into focused componentsMaking it easier to extend behavior through new components
Creating clear, single-purpose interfacesAllowing interchangeable implementations that can be injected or extended
Avoiding tight couplingEnabling safe extension without breaking existing logic
Improving modularityMaking behavior pluggable, testable, and extendable

πŸ“¦ Example: Notifications Revisited

Let's revisit our notification system from earlier, now focusing on SRP + OCP.

❌ Without SRP or OCP:

class NotificationService {
  send(channel: string, message: string) {
    if (channel === 'email') {
      // logic to send email
    } else if (channel === 'sms') {
      // logic to send SMS
    }
  }
}
  • 🚫 Breaks SRP: One class handles message formatting, channel logic, and sending.
  • 🚫 Breaks OCP: You must modify the method every time a new channel is added.

βœ… With SRP and OCP:

Step 1: SRP β€” One Class, One Responsibility

interface Notifier {
  send(message: string): void;
}

class EmailNotifier implements Notifier {
  send(message: string) {
    // Send email
  }
}

class SMSNotifier implements Notifier {
  send(message: string) {
    // Send SMS
  }
}

Each notifier class does only one thing: handles a specific channel.

Step 2: OCP β€” Inject Behavior

class NotificationService {
  constructor(private notifier: Notifier) {}

  notify(message: string) {
    this.notifier.send(message);
  }
}

You can now extend the system by adding new notifiers (e.g. PushNotifier, WhatsAppNotifier) without modifying existing code.

🎯 Combined Benefits

PrincipleWhat It EnsuresHow They Complement
SRPCode has clear, atomic responsibilitiesMakes code more modular and pluggable, a requirement for safe extension
OCPCode can be extended without changing existing partsDepends on responsibilities being already separated to work effectively

🧠 Real-World Insight

In large projects:

  • SRP prepares the codebase for OCP.
  • OCP builds on SRP to ensure code remains stable as it grows.
  • Violating SRP usually leads to violating OCP.

πŸ› οΈ Quick Checklist: When Designing a Class

βœ… Does this class do only one thing? (SRP) βœ… Can I add new features without modifying it? (OCP) βœ… Can each class or component be tested in isolation?

If yes, you're on the right track toward SOLID design.

🧩 Final Thought

Think of SRP as the foundation, and OCP as the architecture built on it.

When you follow SRP, you naturally create the environment where OCP thrives β€” allowing you to scale your application confidently, with fewer bugs and greater agility.

🧩 Summary

The Open/Closed Principle helps create extensible and stable systems. You can handle new requirements easily while minimizing the risk of bugs. This is crucial in large-scale apps where changes are frequent.

By designing your system to be open for extension, closed for modification, you achieve:

  • Clean code separation
  • Better testability
  • Reduced regressions
  • Long-term maintainability