Loading content...
In biology, evolution doesn't modify existing DNA by rewriting it in place. Instead, new variations arise through addition and combination—new genes, new expressions of existing genes, new combinations of traits. The organisms that existed before continue to exist; new forms emerge alongside them.
Software can evolve the same way. Instead of surgically modifying working code (with all the risks we've explored), we can design systems that grow through extension—adding new capabilities without touching what already works.
This is the promise of the Open/Closed Principle: systems that can adapt to new requirements, support new use cases, and integrate new technologies—all by adding code rather than changing it.
By the end of this page, you will understand what extension-based evolution looks like in practice, the mechanisms that enable it (interfaces, composition, configuration), concrete strategies for designing extensible systems, and real-world examples of extension-first architecture.
Extension, in the OCP sense, means adding new behavior to a system without modifying its existing source code. This sounds abstract, so let's make it concrete.
Extension mechanisms:
| Extension Type | How It Works | Example |
|---|---|---|
| New implementation | Create new class implementing an interface | Adding PayPalPayment implementing PaymentProcessor |
| New composition | Compose existing objects in new ways | Combining RetryDecorator with HttpClient |
| Configuration-driven | Enable behavior via config without code changes | Adding new feature via JSON/YAML config |
| Plugin architecture | Load new capabilities at runtime | IDE plugins, browser extensions |
| Event/hook systems | Register new handlers for existing events | Adding webhook handler for order.created |
| Inheritance (careful) | Create subclass that specializes base behavior | Extending Shape with Triangle |
The litmus test for extension
Ask yourself: "Can I add this feature by creating new files, without editing existing files?"
If yes, you're extending. If no, you're modifying.
This isn't perfectly precise (you might add to a config file or a factory's registration), but it's a useful heuristic. The more your changes are isolated to new code, the more you're following OCP.
The extension workflow
Identify the variation point: What kind of change is this? Is it a new payment method? A new export format? A new notification channel?
Find or create the abstraction: What interface or abstract class represents this variation? Does one exist, or do you need to extract it?
Implement the new variation: Create a new class that conforms to the abstraction.
Register the new implementation: Tell the system about your new implementation (via config, factory, dependency injection, etc.).
Done: No existing code was modified. The new capability integrates automatically.
Some purists argue that adding to a configuration file counts as modification. Pragmatically, config changes are far safer than code changes: they're declarative, easily auditable, often reversible, and don't require recompilation. Most teams treat config-only additions as extensions.
The most common mechanism for OCP-compliant extension is the interface (or abstract class). An interface defines a contract; new implementations of that contract provide new behavior.
Why interfaces enable OCP:
Clients depend on the interface, not implementations: The code that uses the functionality doesn't know or care which specific implementation it's using.
New implementations don't affect existing code: Adding a new class that implements the interface doesn't require changing the interface or any client code.
Substitutability: Any implementation can be swapped in wherever the interface is expected (supported by LSP).
Let's see this in action:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// EXTENSION THROUGH INTERFACES // The stable interface - this is the extension pointinterface NotificationChannel { send(recipient: string, message: string): Promise<void>; supports(recipientType: string): boolean;} // Initial implementation - Emailclass EmailChannel implements NotificationChannel { async send(recipient: string, message: string): Promise<void> { // Send email via SMTP console.log(`Sending email to ${recipient}: ${message}`); } supports(recipientType: string): boolean { return recipientType === 'email'; }} // Core notification service - CLOSED for modificationclass NotificationService { constructor(private channels: NotificationChannel[]) {} async notify(recipient: string, recipientType: string, message: string): Promise<void> { for (const channel of this.channels) { if (channel.supports(recipientType)) { await channel.send(recipient, message); return; } } throw new Error(`No channel supports type: ${recipientType}`); }} // === MONTHS LATER: Add SMS support ===// No changes to NotificationService or EmailChannel! class SMSChannel implements NotificationChannel { async send(recipient: string, message: string): Promise<void> { // Send SMS via Twilio console.log(`Sending SMS to ${recipient}: ${message}`); } supports(recipientType: string): boolean { return recipientType === 'sms'; }} // === LATER STILL: Add Slack support ===// Still no changes to anything existing! class SlackChannel implements NotificationChannel { async send(recipient: string, message: string): Promise<void> { // Send Slack message via webhook console.log(`Sending Slack to ${recipient}: ${message}`); } supports(recipientType: string): boolean { return recipientType === 'slack'; }} // Configuration/wiring (the only "change" needed)const service = new NotificationService([ new EmailChannel(), new SMSChannel(), // Added via config/DI new SlackChannel(), // Added via config/DI]); // All channels work through the same code pathawait service.notify("user@example.com", "email", "Hello!");await service.notify("+1234567890", "sms", "Hello!");await service.notify("@user", "slack", "Hello!");Key observations:
NotificationService never changes, no matter how many channels are addedNotificationChannel) creates the extension pointThis is extension in its purest form: new capabilities through new code, with zero modification to existing code.
Extension is safer than modification for fundamental reasons. Let's examine the properties that make it so:
Modification properties (risks):
Extension properties (safety):
When teams consistently practice extension-first development, trust accumulates. Code reviews are faster (less need to verify impact on existing code). Deployments are smoother (less fear of regression). Engineers are happier (more time creating, less time defending against breakage).
Extension doesn't happen by accident. Systems must be designed to be extensible. Here are the key strategies:
Strategy 1: Identify Variation Points Early
During design, ask: "Where will this system likely need to change?"
Common variation points include:
For each variation point, consider whether to create an abstraction now or wait until variation is actually needed (see YAGNI balance later).
Strategy 2: Depend on Abstractions
The Dependency Inversion Principle (DIP) states: depend on abstractions, not concretions. This is the mechanism that enables OCP.
// WRONG: Depends on concrete class
class OrderService {
private payment = new StripePayment(); // Closed to extension!
}
// RIGHT: Depends on abstraction
class OrderService {
constructor(private payment: PaymentProcessor) {} // Open for extension!
}
When you depend on abstractions, new implementations of those abstractions automatically work.
Strategy 3: Use Composition Over Inheritance
Inheritance creates tight coupling. Composition creates loose coupling with explicit extension points.
// INHERITANCE: Tight coupling, limited extension
class EnhancedLogger extends BaseLogger {
log(msg: string) {
// Must understand BaseLogger's implementation
super.log(this.enhance(msg));
}
}
// COMPOSITION: Loose coupling, flexible extension
class EnhancedLogger implements Logger {
constructor(private inner: Logger, private enhancer: Enhancer) {}
log(msg: string) {
// Doesn't need to know inner's implementation
this.inner.log(this.enhancer.enhance(msg));
}
}
Composition allows mixing and matching behaviors without inheritance hierarchies.
Strategy 4: Leverage Configuration and Registration
Extensible systems often separate what's available from what's active:
// Extension registry pattern
class PaymentRegistry {
private processors: Map<string, PaymentProcessor> = new Map();
register(name: string, processor: PaymentProcessor): void {
this.processors.set(name, processor);
}
get(name: string): PaymentProcessor {
const processor = this.processors.get(name);
if (!processor) throw new Error(`Unknown payment: ${name}`);
return processor;
}
}
// Configuration file (e.g., payments.json)
{
"enabled": ["stripe", "paypal"],
"default": "stripe"
}
// Adding a new payment type:
// 1. Create ApplePayProcessor class
// 2. Register it in the registry
// 3. Add "applepay" to config
// NO modification to existing code
This pattern separates extension (adding to registry) from activation (updating config).
Not every piece of code needs to be extensible from day one. A practical heuristic: the first time you need a variation, implement it inline. The second time, note the pattern. The third time, refactor to an extension point. This balances OCP with YAGNI (You Aren't Gonna Need It).
Let's examine how major software systems leverage extension to evolve without modification:
Pattern 1: Plugin Architecture
Plugins are the ultimate expression of OCP. The core system defines extension points; plugins provide implementations that load dynamically.
Examples:
How it works:
Key insight: The core is "closed" (stable, trusted) while the ecosystem is "open" (constantly growing).
Pattern 2: Event-Driven Systems
In event-driven architectures, new functionality is added by subscribing to events—no modification of the event producers.
Examples:
How it works:
// Event system (closed)
eventBus.emit('order.created', { orderId: '123', total: 99.99 });
// Extension 1: Send confirmation email
eventBus.on('order.created', async (order) => {
await sendConfirmationEmail(order);
});
// Extension 2: Update inventory (added later)
eventBus.on('order.created', async (order) => {
await decrementInventory(order);
});
// Extension 3: Notify warehouse (added even later)
eventBus.on('order.created', async (order) => {
await notifyWarehouse(order);
});
The order creation code never changes. New behaviors are added by registering handlers.
Pattern 3: Feature Flags as Extension
Modern feature flag systems enable extension-like behavior by conditionally activating capabilities.
Example:
// Feature flag configuration (not code modification)
{
"newPaymentFlow": {
"enabled": true,
"percentage": 10 // 10% of users
}
}
// Code (written once, never modified)
if (featureFlags.isEnabled('newPaymentFlow', user)) {
return newPaymentProcessor.process(order);
} else {
return legacyPaymentProcessor.process(order);
}
New payment flows are built as new classes, enabled through config. The branching code is stable—only the config changes.
Note: While feature flags can be misused (accumulating stale branches), when used as an extension mechanism for deployment and experimentation, they're powerful.
Pattern 4: Middleware/Decorator Chains
Middleware patterns allow extending behavior by composing processing steps.
Example:
// Express middleware (each is independent, composable)
app.use(loggingMiddleware); // Extension 1
app.use(authenticationMiddleware); // Extension 2
app.use(rateLimitingMiddleware); // Extension 3
app.use(corsMiddleware); // Extension 4
// Adding new behavior: just add another middleware
app.use(newSecurityMiddleware); // Extension 5 - no existing code changed
Each middleware is self-contained. Adding new middleware doesn't modify existing middleware. The core routing remains unchanged.
While OCP originated in OOP contexts, extension patterns appear everywhere: functional programming (higher-order functions), data pipelines (new transformers), configuration-driven systems (new configs), and service architectures (new microservices). The principle transcends paradigms.
While extension is often safer than modification, designing for perfect extensibility everywhere leads to over-engineering. Pragmatic application of OCP requires judgment.
The YAGNI balance
YAGNI (You Aren't Gonna Need It) cautions against building for hypothetical future needs. OCP cautions against modification. These can feel like they conflict.
The resolution:
Don't add extension points for imagined variations. If you've never needed a second payment processor, don't abstract one.
Do refactor to extension points when patterns emerge. When you need the second variation, that's the signal to refactor.
Prefer reversible designs. If you're unsure, choose structures that can become extension points later without major rewrite.
Accept that some modification is inevitable. Even well-designed systems occasionally need modification. The goal is minimizing it, not eliminating it entirely.
Don't become an 'architecture astronaut'—someone who designs infinitely flexible systems that never ship. OCP is a tool for managing real complexity, not a mandate to abstract everything. Pragmatic engineers extend where it matters and modify where it's simpler.
We've explored how extension provides a safer evolutionary path than modification. Let's consolidate:
Module Complete
You've now completed Module 1: What Is OCP? You understand the principle's definition, its historical origins with Bertrand Meyer, why modification is risky, and how extension provides a safer alternative. You're ready to dive deeper into OCP's mechanics—how to achieve it, patterns that support it, and common violations to avoid.
Congratulations! You've mastered the foundational concepts of the Open/Closed Principle. You understand what OCP is, where it came from, why it exists, and how it guides system evolution. The next module will explore OCP's mechanics in detail—what 'open' and 'closed' really mean and how abstraction bridges them.