Loading content...
The traditional presentation of OCP implies a single, upfront design phase: analyze requirements, identify variation points, design abstractions, implement. This waterfall-style approach to extensibility fails for the same reasons waterfall development fails—it assumes we know more than we do, more than we can.
Iterative OCP takes a fundamentally different approach. Instead of trying to design the perfect abstractions upfront, we:
This approach treats OCP as an ongoing discipline rather than an upfront decision. Extensibility emerges from experience with the system, not speculation about its future.
By the end of this page, you will understand how to apply OCP incrementally through continuous refactoring, learn patterns for safely evolving abstractions, and develop a mindset that embraces evolutionary design. You'll see OCP not as a constraint to apply upfront but as a discipline to practice continuously.
Evolutionary design rests on a core insight: we learn more about what a system needs as we build and operate it. The right abstractions become clear only after we've experienced the pressures that make them valuable.
The evolutionary premise:
Contrasting approaches:
Evolutionary design requires the ability to change code safely. This means comprehensive automated tests, continuous integration, and a team culture that values refactoring. Without these, evolutionary design becomes evolutionary chaos.
Here's a concrete cycle for applying OCP iteratively. Each iteration adds extensibility where it's earned, not where it's imagined.
12345678910111213141516171819202122232425262728293031323334353637383940
## The Iterative OCP Cycle ### Phase 1: Implement ConcretelyBuild the simplest solution that solves the current problem.- No interfaces where you have only one implementation- No factories where you have only one type- No strategies where you have only one algorithm- Direct, obvious code that clearly expresses intent ### Phase 2: Observe Change PatternsTrack how the code changes over time.- What parts of the code change frequently?- What kinds of changes are requested?- Where do modifications feel painful?- What patterns emerge in change requests? ### Phase 3: Identify Abstraction OpportunitiesWhen real variation appears, recognize it.- Second implementation of similar logic? Consider interface.- Third time changing the same code? Time to abstract.- Change in one place requires changes elsewhere? Seam needed.- External pressure (new vendor, new channel)? Boundary abstraction. ### Phase 4: Refactor to ExtensibilityAdd abstraction surgically at identified points.- Extract interface from existing implementation- Apply strategy pattern to varying behavior- Introduce factory when selection logic emerges- Publish events to decouple new subscribers ### Phase 5: Validate and IterateConfirm the abstraction provides value.- Does the extension point get used?- Is the abstraction at the right level?- Did it make the recent change easier?- Are there other points that now seem worth abstracting? ### Return to Phase 1Continue building concrete solutions for new features.The cycle never ends—it's how healthy codebases evolve.The cycle in action:
Imagine building an e-commerce order system:
Iteration 1: Simple order processing with hardcoded shipping calculation.
Iteration 2: Free shipping for orders over $50 requested. Modify the hardcoded calculation. Note: shipping logic changed.
Iteration 3: Weight-based shipping required for heavy items. Now we've modified shipping twice. This is a pattern. Time to extract a ShippingCalculator interface.
Iteration 4: International shipping needed. Add new ShippingCalculator implementation. The abstraction pays off—no core order processing changes needed.
Iteration 5: Business wants to set shipping rules without code changes. Consider configuration-driven strategy selection. But wait—have we had many requests for this? Maybe not yet. Defer until we have more evidence.
Iterative OCP requires patience. The temptation to abstract early is strong, especially for experienced developers who 'know' certain changes are coming. Resist. The cost of premature abstraction exceeds the cost of slightly-late abstraction. You can always add an interface; removing one that's woven throughout the codebase is much harder.
When you identify a point that deserves an extension point, these refactoring patterns ensure safe evolution without breaking existing functionality.
123456789101112131415161718192021222324252627282930
// BEFORE: Concrete dependencyclass OrderService { private shippingService = new ShippingService(); processOrder(order: Order) { const shipping = this.shippingService.calculate(order); // ... }} // AFTER: Interface extracted, original implementation preservedinterface ShippingCalculator { calculate(order: Order): Money;} class ShippingService implements ShippingCalculator { calculate(order: Order): Money { /* same implementation */ }} class OrderService { constructor(private shippingCalculator: ShippingCalculator) {} processOrder(order: Order) { const shipping = this.shippingCalculator.calculate(order); // ... }} // Can now add InternationalShippingService, // FreeShippingCalculator, etc.1234567891011121314151617181920212223242526272829
// BEFORE: Conditional on type (OCP violation)function calculateDiscount(order: Order): Money { switch (order.customerType) { case 'regular': return order.subtotal.multiply(0.05); case 'premium': return order.subtotal.multiply(0.15); case 'vip': return order.subtotal.multiply(0.25); default: return Money.zero(); }} // AFTER: Polymorphic discount strategiesinterface DiscountStrategy { apply(order: Order): Money;} class RegularDiscount implements DiscountStrategy { apply(order: Order): Money { return order.subtotal.multiply(0.05); }} class PremiumDiscount implements DiscountStrategy { apply(order: Order): Money { return order.subtotal.multiply(0.15); }} class VIPDiscount implements DiscountStrategy { apply(order: Order): Money { return order.subtotal.multiply(0.25); }} // New customer types = new DiscountStrategy implementations// No modification to existing code neededThese patterns are safe only when you have test coverage. Changing code structure without tests risks introducing bugs that go undetected until production. If you lack tests, write them first—characterization tests that capture current behavior—before refactoring.
Sometimes existing abstractions need to evolve—they no longer fit emerging requirements. This is normal in iterative OCP, but evolving abstractions requires care.
| Strategy | When to Use | How It Works |
|---|---|---|
| Extend, Don't Modify | Interface needs new capability | Add new interface that extends the old; implementers migrate gradually |
| Parallel Implementation | Radical change needed | Build new abstraction alongside old; migrate consumers incrementally; remove old when unused |
| Adapter Pattern | Old interface must remain for some clients | New abstraction internally; adapters convert for old interface consumers |
| Feature Toggle Migration | High-risk abstraction change | New abstraction behind feature flag; compare behavior before committing |
| Strangler Pattern | Legacy abstraction deeply embedded | Route new code to new abstraction; gradually redirect old code; legacy shrinks until removable |
| Deprecation with Timeline | Forced migration eventual | Mark old abstraction deprecated; provide migration path; remove after deadline |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Original abstraction - served well but now limitinginterface PaymentProcessor { process(amount: Money): PaymentResult;} // New requirements: idempotency keys, metadata, async processing// Option 1: Add new methods to interface (breaks existing implementers)// Option 2: Create new, better interface // OPTION 2: Extend and migrateinterface PaymentProcessorV2 extends PaymentProcessor { processAsync(request: PaymentRequest): Promise<PaymentResult>;} // Adapter for consumers that need the old interfaceclass PaymentProcessorV2Adapter implements PaymentProcessor { constructor(private v2Processor: PaymentProcessorV2) {} process(amount: Money): PaymentResult { // Synchronous wrapper around async processing const request = new PaymentRequest({ amount }); return runSync(() => this.v2Processor.processAsync(request)); }} // Migration path:// 1. New code uses PaymentProcessorV2 directly// 2. Old consumers continue using PaymentProcessor// 3. Gradually migrate old consumers to V2// 4. When all consumers migrated, remove PaymentProcessor interface // ALTERNATIVELY: Strangler patternclass PaymentService { constructor( private legacyProcessor: PaymentProcessor, private newProcessor: PaymentProcessorV2, private useNewProcessor: (amount: Money) => boolean ) {} process(amount: Money): PaymentResult { // Routes to new processor for some cases // Eventually all cases go to new processor // Then remove legacy completely if (this.useNewProcessor(amount)) { const request = new PaymentRequest({ amount }); return await this.newProcessor.processAsync(request); } return this.legacyProcessor.process(amount); }}When evolving abstractions, define a migration window: a period during which both old and new abstractions coexist. This window should be long enough for consumers to migrate but not so long that the old abstraction becomes entrenched. Set explicit deadlines and track migration progress.
Iterative OCP improves over time if you build feedback loops that surface insight about your abstractions.
Feedback mechanisms:
Questions for regular review:
| Question | Healthy Answer | Warning Sign |
|---|---|---|
| How many implementations? | 2+ actively used | Still only one after years |
| When was the last extension? | Recent; extension points used regularly | Never; extension system unused |
| Is the abstraction easy to understand? | New team members grasp it quickly | Requires extensive explanation |
| Does it match the domain? | Domain experts recognize concepts | Translation required to explain |
| Are changes getting easier? | Extension points accelerate delivery | Still hard despite abstraction |
| Would we design it the same way today? | Yes, or minor tweaks | No, we've learned a lot |
Don't be afraid to deprecate abstractions that didn't work out. Removing an abstraction that never provided value is a win—it reduces complexity. Every abstraction you remove is one less thing for the team to maintain and understand. Deprecation is evidence of organizational learning.
Iterative OCP is a team discipline, not an individual technique. It requires shared understanding and consistent practices across the team.
Essential team practices:
Code review checklist for OCP:
1234567891011121314151617181920212223
## OCP Code Review Checklist ### For New Abstractions- [ ] Is there observable evidence of variation justifying this abstraction?- [ ] Do we have 2+ implementations, or a clear second one coming soon?- [ ] Is the interface minimal? Only methods actually used by clients?- [ ] Would a simpler approach work for now? ### For Changes to Existing Code- [ ] Did this change reveal an extension point opportunity?- [ ] Is there a pattern in recent changes suggesting we should abstract?- [ ] Did the changes fight existing abstractions? If so, why?- [ ] Are we modifying code that was "supposed" to be closed? ### For Removing/Simplifying- [ ] Is there an abstraction we can remove because it never got used?- [ ] Can we simplify an over-engineered component?- [ ] Are there deprecated abstractions we should finally delete? ### General- [ ] Did we add tests that will make future refactoring safe?- [ ] Is the design simple enough that a new team member could understand quickly?- [ ] If we're adding complexity, is the justification clear and documented?Checklists and practices help, but iterative OCP ultimately rests on culture: a shared belief that simplicity is valuable, that abstractions must be earned, that refactoring is normal work, and that good design emerges over time. This culture must be modeled by senior developers and reinforced through feedback.
Iterative OCP doesn't mean endless churn. Some parts of a system reach a stable state where the abstractions fit the actual variation. Recognizing this allows teams to focus refactoring effort where it's still needed.
Signs that abstraction has converged:
| Indicator | What It Means | Example |
|---|---|---|
| Extension usage | Extension points are actively used to add new behavior | 3 shipping strategies; new ones added without core changes |
| Change locality | Changes are contained within single components | New payment type added without modifying order processing |
| Declining change frequency | The abstraction itself changes rarely | ShippingCalculator interface unchanged for 2 years |
| Developer confidence | Developers understand and trust the abstraction | Team extends the system confidently via well-known patterns |
| Match with domain | Abstraction mirrors how stakeholders think | Business talks about 'shipping rules' matching ShippingPolicy |
When to stop iterating on an abstraction:
When an abstraction reaches this state, leave it alone. Focus refactoring effort on parts of the system that are still evolving or that have been identified as problematic.
The maturity gradient:
Not all parts of a system evolve at the same rate. A mature e-commerce platform might have:
Different maturity levels warrant different approaches: stable areas need maintenance; maturing areas need careful evolution; exploratory areas need flexibility and learning orientation.
Visualize your codebase as a heat map of change frequency. Hot spots need attention—they're either poorly structured or in domains that are still evolving. Cool spots can be left alone. Focus OCP refinement on hot spots where investment will pay off in the near term.
Throughout this module, we've built a pragmatic approach to the Open/Closed Principle—one that respects the principle's intent while acknowledging real-world constraints.
The integrated approach:
The journey of an extension point:
This journey takes months or years. Patience and discipline are required. But the result is a system that is genuinely open where it needs to be and appropriately closed everywhere else—not because we predicted correctly, but because we learned from reality.
The Open/Closed Principle isn't something you 'achieve.' It's a discipline you practice. Every codebase is always in the process of becoming more appropriately open and closed. The goal is continuous improvement, not perfection.
We've completed a deep exploration of how to apply the Open/Closed Principle in the real world—where uncertainty reigns, costs are real, and perfect foresight is impossible.
The meta-principle:
Build the simplest thing that works. Watch what actually changes. Refactor to accommodate the patterns you observe. Repeat indefinitely.
This is iterative OCP. It's less glamorous than building elaborate architectures. It requires humility—admission that we don't know the future. But it results in systems that are genuinely maintainable, genuinely extensible, and—perhaps most importantly—genuinely simple.
The best code is code that was refactored into shape by developers responding to real needs, not code that was over-designed based on speculation. Pragmatic OCP is how good design actually happens.
Congratulations! You've completed the module on Balancing OCP with Pragmatism. You understand that OCP is not about perfect prediction—it's about disciplined evolution. You can recognize when to invest in extensibility and when simplicity is the better choice. You're equipped to apply OCP iteratively, refactoring as understanding grows, building systems that are open where it matters and appropriately closed everywhere else.