Loading learning content...
Every software system changes. Requirements evolve. Business pivots. Technologies shift. The question isn't whether change will come—it's how painful that change will be.
The Open/Closed Principle promises that well-designed systems can accommodate change through extension rather than modification. But achieving this requires designing for change upfront—making educated predictions about where variation will occur and building extension points at those locations.
This is a delicate balance. Too little preparation, and every change requires invasive surgery. Too much speculation, and you've built a framework when you needed an application. This page explores how to find the middle path: designing systems that gracefully accommodate likely changes without drowning in premature abstraction.
By the end of this page, you will understand how to identify likely points of variation, distinguish strategic from speculative extension points, apply the right patterns at the right time, and evolve designs incrementally as requirements clarify.
Change doesn't arrive randomly. Experienced engineers recognize patterns—recurring categories of change that systems must accommodate. Understanding these categories helps identify where extension points provide value.
Common sources of change:
| Change Category | Examples | Extension Point Strategy |
|---|---|---|
| Business Rules | Tax calculations, discount policies, eligibility rules | Strategy pattern, rule engines, configuration |
| External Integrations | Payment gateways, shipping providers, authentication services | Adapter pattern, interface abstractions |
| Data Sources | Databases, APIs, file systems, caches | Repository pattern, data access interfaces |
| Output Formats | JSON, XML, PDF, CSV exports | Template method, serialization interfaces |
| Platform/Environment | Cloud providers, operating systems, runtime versions | Abstraction layers, platform interfaces |
| User Interface | Web, mobile, API, CLI | Presentation layer separation, MVC/MVVM |
| Algorithm Variations | Sorting, routing, pricing strategies | Strategy pattern, function injection |
| Feature Toggles | A/B testing, gradual rollouts, premium features | Feature flag systems, decorator pattern |
Analyzing your domain:
Every domain has characteristic change patterns:
Understanding your domain's typical change vectors guides where to invest in extensibility.
Look at your codebase's git history. Which files change most frequently? Which changes required widespread modifications? History reveals where extensibility would have helped—and where it's still needed.
Not all potential extension points deserve investment. The key distinction is between strategic and speculative extensibility.
Strategic extension points:
Speculative extension points:
The cost of speculation:
Speculative extensibility isn't free:
The guideline: Build for what you know; refactor when you learn.
You Aren't Gonna Need It (YAGNI) is the counterbalance to OCP. The principle isn't 'always build extension points'—it's 'when extension is needed, enable it without modification.' If extension isn't needed, simple code beats flexible code.
Certain patterns in requirements, code, and team structure signal where extension points provide genuine value.
Code smells that suggest missing extension points:
1234567891011121314151617181920212223242526272829303132333435
// SMELL 1: Type-checking conditionals// Every new type requires modifying this functionfunction processPayment(payment: Payment): Result { if (payment.type === 'CREDIT_CARD') { return processCreditCard(payment); } else if (payment.type === 'PAYPAL') { return processPayPal(payment); } else if (payment.type === 'APPLE_PAY') { // Added later return processApplePay(payment); } // ... grows with each new payment type} // SMELL 2: Duplicate structures across typesfunction formatForEmail(order: Order): string { /* format logic */ }function formatForSMS(order: Order): string { /* similar format logic */ }function formatForSlack(order: Order): string { /* similar format logic */ }// Adding a new channel means adding another function // SMELL 3: Configuration that controls behavior switchingfunction calculateShipping(order: Order): number { if (config.shippingProvider === 'ups') { return calculateUPS(order); } else if (config.shippingProvider === 'fedex') { return calculateFedEx(order); } // Configuration-driven behavior switching suggests strategy pattern} // SMELL 4: Hard-coded external service callsasync function chargeCustomer(amount: number): Promise<void> { await fetch('https://api.stripe.com/v1/charges', { // Direct coupling to specific payment provider });}When you see these patterns, you've found candidates for extension points. The if-else chains become polymorphic dispatch. The duplicate functions become interface implementations. The config switches become injected strategies.
The most pragmatic approach to OCP isn't "design for all possible extensions upfront"—it's evolve the design as requirements clarify.
The evolutionary approach:
This is the Rule of Three applied to design: don't abstract until you have three concrete cases. Two might be coincidence; three establishes a pattern.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// STAGE 1: First implementation (simple, direct)// Only email notifications exist - no abstraction neededclass NotificationService { async notify(user: User, message: string): Promise<void> { await this.emailClient.send({ to: user.email, subject: 'Notification', body: message }); }} // STAGE 2: Second variation arrives (refactor to interface)// SMS notifications requested - NOW we create abstractioninterface NotificationChannel { send(recipient: string, message: string): Promise<boolean>; getRecipientFor(user: User): string | null;} class EmailChannel implements NotificationChannel { async send(recipient: string, message: string): Promise<boolean> { return await this.emailClient.send({ to: recipient, body: message }); } getRecipientFor(user: User): string | null { return user.email; }} class SMSChannel implements NotificationChannel { async send(recipient: string, message: string): Promise<boolean> { return await this.smsGateway.send(recipient, message); } getRecipientFor(user: User): string | null { return user.phoneNumber; }} class NotificationService { constructor(private channels: NotificationChannel[]) {} async notify(user: User, message: string): Promise<void> { for (const channel of this.channels) { const recipient = channel.getRecipientFor(user); if (recipient) { await channel.send(recipient, message); } } }} // STAGE 3: Third+ variations (pattern established, easy additions)// Push notifications, Slack, webhooks - just add new implementationsclass PushChannel implements NotificationChannel { /* ... */ }class SlackChannel implements NotificationChannel { /* ... */ }class WebhookChannel implements NotificationChannel { /* ... */ }// NotificationService NEVER changes!Why this works:
The refactoring investment:
The Stage 1→2 transition requires refactoring. This is intentional. A small, known refactoring cost beats a large, speculative abstraction cost. And because the refactoring happens when you understand the variation, the abstraction fits the actual requirements.
This evolutionary approach depends on safe refactoring. Invest in tests that verify behavior so that extracting interfaces and introducing polymorphism can be done with confidence. Without tests, the Stage 1→2 transition becomes risky surgery.
When you've identified strategic extension points, several patterns provide proven structures for OCP-compliant design:
When to use: Behavior varies based on context, configuration, or type—but the surrounding workflow is constant.
Structure: Define a strategy interface. Inject strategies into the context class. New behaviors are new strategy implementations.
12345678910111213141516171819202122232425262728293031323334353637383940
interface PricingStrategy { calculatePrice(basePrice: number, context: PricingContext): number;} class StandardPricing implements PricingStrategy { calculatePrice(basePrice: number, context: PricingContext): number { return basePrice; }} class DiscountPricing implements PricingStrategy { constructor(private discountPercent: number) {} calculatePrice(basePrice: number, context: PricingContext): number { return basePrice * (1 - this.discountPercent / 100); }} class DynamicPricing implements PricingStrategy { calculatePrice(basePrice: number, context: PricingContext): number { // Complex pricing based on demand, inventory, etc. return basePrice * this.getDemandMultiplier(context); }} class CheckoutService { constructor(private pricingStrategy: PricingStrategy) {} checkout(items: CartItem[]): Order { const total = items.reduce((sum, item) => { const price = this.pricingStrategy.calculatePrice( item.basePrice, item.context ); return sum + price * item.quantity; }, 0); return new Order(items, total); }}Each pattern addresses a specific type of variability:
Recognizing which pattern fits your extension need guides the design.
OCP is a principle, not a law. Real-world engineering requires balancing ideals with practical constraints.
Questions to ask before investing in extensibility:
What's the probability this will change? — Based on roadmap, history, and domain knowledge, not intuition.
What's the cost of change without an extension point? — Sometimes modifying code is cheaper than the abstraction overhead.
Who needs to extend this? — Internal team only? External developers? Different extension levels require different investments.
What's the cost of the abstraction? — Indirection, complexity, learning curve, maintenance burden.
Can we defer the decision? — If refactoring later is low-risk, maybe wait until the second or third case.
The right question isn't 'Should this be extensible?' but 'Does the expected benefit of extensibility exceed its cost?' When expected changes are unlikely, impact is low, or abstraction cost is high, direct code modification may be the pragmatic choice.
We've explored how to apply OCP thoughtfully—building extension points where they matter while avoiding speculative over-engineering.
Module complete:
You've now explored the full spectrum of achieving OCP through abstraction:
These tools, applied with judgment, enable systems that grow through addition rather than modification—the hallmark of sustainable software design.
You now have a comprehensive understanding of achieving the Open/Closed Principle through abstraction. From interface extension points to plugin architectures to pragmatic trade-off decisions, you're equipped to design systems that welcome change gracefully.