Published on

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

πŸ“˜ Mastering the Open/Closed Principle in TypeScript with Real-World Examples

πŸš€ Introduction

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.

That means your system should allow you to add new functionality without changing existing code.

❌ 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