- Published on
Mastering the Open/Closed Principle in TypeScript with Real-World Examples
- β Bad Example β Violating OCP
- 𧨠Problems:
- β Good Example β Applying OCP with Strategy Pattern
- β¨ Benefits of Good (OCP-Compliant) Code
- π§ How to Detect the Need for OCP
- π οΈ Techniques to Apply the Open/Closed Principle
- π§© Advanced Real-World Scenario
- π§ͺ Unit Testing Advantage
- π§ Pro Tips
- π Relationship Between OCP and SRP
- π How SRP Supports OCP
- π¦ Example: Notifications Revisited
- π― Combined Benefits
- π§ Real-World Insight
- π οΈ Quick Checklist: When Designing a Class
- π§© Final Thought
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 extend | Add new functionality without touching existing logic |
Safer changes | Reduced risk of breaking existing features |
Better testability | Each strategy (payment method) can be unit-tested in isolation |
Higher maintainability | Clear separation of responsibilities |
Enhanced readability | Code is easier to reason about |
Promotes team collaboration | Multiple 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
orswitch
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
Technique | Description |
---|---|
Use Interfaces/Abstract Classes | Allow implementation to vary without changing usage |
Strategy Pattern | Encapsulate different behaviors behind a common interface |
Template Method Pattern | Define a base algorithm with customizable steps |
Factory Pattern | Delegate object creation to a factory to avoid hard-coded classes |
Dependency Injection | Inject 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 components | Making it easier to extend behavior through new components |
Creating clear, single-purpose interfaces | Allowing interchangeable implementations that can be injected or extended |
Avoiding tight coupling | Enabling safe extension without breaking existing logic |
Improving modularity | Making 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
Principle | What It Ensures | How They Complement |
---|---|---|
SRP | Code has clear, atomic responsibilities | Makes code more modular and pluggable, a requirement for safe extension |
OCP | Code can be extended without changing existing parts | Depends 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