How to Refactor Code that Violates Open-Closed Principle?
Each new customer type means modifying existing code, risking breaking old logic.
🟢 Junior Level
Problem: Code violates the Open-Closed Principle when adding new functionality requires modifying already working methods. Most often, this manifests as long chains of if-else or switch by object type.
Simple violation example:
public class DiscountCalculator {
public double calculateDiscount(String customerType, double amount) {
if ("regular".equals(customerType)) {
return amount * 0.05; // 5% discount
} else if ("vip".equals(customerType)) {
return amount * 0.15; // 15% discount
} else if ("premium".equals(customerType)) {
return amount * 0.25; // 25% discount
}
return 0;
}
}
Each new customer type means modifying existing code, risking breaking old logic.
Basic refactoring approach:
Step 1: Extract an interface
public interface DiscountStrategy {
double calculateDiscount(double amount);
String getCustomerType();
}
Step 2: Create separate classes for each branch
public class RegularDiscount implements DiscountStrategy {
@Override
public double calculateDiscount(double amount) {
return amount * 0.05;
}
@Override
public String getCustomerType() { return "regular"; }
}
public class VipDiscount implements DiscountStrategy {
@Override
public double calculateDiscount(double amount) {
return amount * 0.15;
}
@Override
public String getCustomerType() { return "vip"; }
}
Step 3: Use a Map for strategy selection
public class DiscountCalculator {
private final Map<String, DiscountStrategy> strategies;
public DiscountCalculator(List<DiscountStrategy> strategyList) {
this.strategies = new HashMap<>();
for (DiscountStrategy s : strategyList) {
strategies.put(s.getCustomerType(), s);
}
}
public double calculateDiscount(String customerType, double amount) {
DiscountStrategy strategy = strategies.get(customerType);
if (strategy == null) {
throw new IllegalArgumentException("Unknown type: " + customerType);
}
return strategy.calculateDiscount(amount);
}
}
Now a new discount type is just one new class, without modifying existing code.
🟡 Middle Level
How to Recognize OCP Violation
Main signs:
- Switch/If by type:
if (obj instanceof X),switch (type),switch (enum) - God Methods: Methods of 100+ lines that “do a bit of everything”
- Regression bugs: Adding a new feature breaks old functionality
- Merge Conflicts: Multiple developers editing the same file simultaneously
Step-by-Step Refactoring
Before (OCP violation):
public class NotificationService {
public void send(String channel, String message) {
switch (channel) {
case "email":
sendEmail(message);
break;
case "sms":
sendSms(message);
break;
case "push":
sendPush(message);
break;
case "telegram":
sendTelegram(message);
break;
// Each new channel — modifying this method!
}
}
}
After (Strategy + Factory pattern):
public interface NotificationChannel {
String getType();
void send(String message);
}
public class EmailChannel implements NotificationChannel {
@Override
public String getType() { return "email"; }
@Override
public void send(String message) { /* email logic */ }
}
public class SmsChannel implements NotificationChannel {
@Override
public String getType() { return "sms"; }
@Override
public void send(String message) { /* sms logic */ }
}
public class NotificationService {
private final Map<String, NotificationChannel> channels;
public NotificationService(List<NotificationChannel> channelList) {
this.channels = channelList.stream()
.collect(Collectors.toMap(NotificationChannel::getType, c -> c));
}
public void send(String channelType, String message) {
NotificationChannel channel = channels.get(channelType);
if (channel == null) {
throw new IllegalArgumentException("Unknown channel: " + channelType);
}
channel.send(message);
}
}
Safe Refactoring: Strategy
- Cover with tests the existing code (even if it’s “ugly”)
- Create an abstraction (interface)
- Implement one by one class for each branch
- Replace switch/if with delegation
- Delete the old code
- Run tests — behavior should not change
Common Refactoring Mistakes
-
Mistake: Refactoring without tests Solution: Always start by writing tests for current behavior
-
Mistake: Creating abstraction “just in case” Solution: Refactor only when there’s a real need for extension
-
Mistake: Excessive decomposition (each if — a separate class) Solution: Group related logic; not every
ifdeserves its own class -
Mistake: Forgetting to delete old code Solution: Dead code confuses subsequent developers — delete boldly
When Refactoring is NOT Needed
- Code is stable and hasn’t changed in years
- Prototype / proof of concept
- Few branches (2-3) and no new ones expected
- Refactoring cost exceeds support cost
How to Choose a Refactoring Strategy
- Pattern Matching — for closed hierarchies (sealed interface), when the set of types is known
- Strategy — when implementations are added dynamically (plugins, configuration)
- Visitor — when there are many different operations over one hierarchy (taxes, reports, validation)
- SPI (ServiceLoader) — for libraries, when implementations are loaded from classpath
SPI does not support dependency injection — each class must have a no-arg constructor.
🔴 Senior Level
Internal Implementation and Architecture
OCP refactoring is not just “replace switch with strategy”. It’s redesigning extension points of the system. At the Senior level, it’s important to understand:
Refactoring to OCP is an investment in reducing TCO (Total Cost of Ownership). It only pays off if the system will actually be extended.
Refactoring Strategies
1. Pattern Matching (Java 21+)
Instead of full refactoring, modern Java capabilities can be used:
public double calculateDiscount(Object customer, double amount) {
return switch (customer) {
case RegularCustomer c -> amount * 0.05;
case VipCustomer c -> amount * 0.15;
case PremiumCustomer c -> amount * 0.25;
case null, default -> 0;
};
}
Trade-off: This is still an OCP violation (need to modify the switch for a new type), but the compiler will warn about missing cases. Acceptable for closed hierarchies (sealed interface).
// Why is this an OCP violation? When adding GoldCustomer, you’ll have to // come back to this switch and add a new case. You are MODIFYING // existing code, not extending.
2. Double Dispatch (Visitor Pattern)
For complex hierarchies with multiple operations:
public interface Customer {
<R> R accept(CustomerVisitor<R> visitor);
}
public interface CustomerVisitor<R> {
R visit(RegularCustomer c);
R visit(VipCustomer c);
R visit(PremiumCustomer c);
}
public class DiscountVisitor implements CustomerVisitor<Double> {
private final double amount;
public DiscountVisitor(double amount) { this.amount = amount; }
@Override
public Double visit(RegularCustomer c) { return amount * 0.05; }
@Override
public Double visit(VipCustomer c) { return amount * 0.15; }
@Override
public Double visit(PremiumCustomer c) { return amount * 0.25; }
}
3. Plugin Registry (SPI — Service Provider Interface)
For truly extensible systems:
public interface DiscountProvider {
boolean supports(String customerType);
double calculate(double amount);
}
// In META-INF/services/com.example.DiscountProvider
// register all implementations
Architectural Trade-offs
Full refactoring to OCP:
- ✅ Pros: Clean architecture, easy extension, minimum regressions
- ❌ Cons: High refactoring cost, regression risk during migration, more classes
Partial refactoring (sealed types, enums):
- ✅ Pros: Fast, compiler helps, minimum boilerplate
- ❌ Cons: Still need to modify code when adding a type
Without refactoring:
- ✅ Pros: Zero cost now
- ❌ Cons: Technical debt accumulation, development slowdown in the future
Edge Cases
- Legacy Code Without Tests: How to refactor?
- Approach: Characterization Tests — write tests that capture current behavior, even if it’s “ugly”
- Tool: Approval Tests, Golden Master Testing
Strangler Fig Pattern — gradual replacement of old system with new through a facade. Like a vine enveloping a tree: new code gradually takes over functionality from the old.
Characterization Tests — tests that capture current behavior of legacy code. You don’t know what’s “correct”, but you know what “currently works this way”.
Golden Master — saved reference output of the system for comparison after refactoring.
- Cross-cutting Concerns: When switch affects 5+ modules
- Approach: Strangler Fig Pattern — gradually replace old code with new, routing through a facade
- Runtime Discovery: When new implementations are loaded dynamically (plugins)
- Approach:
ServiceLoader, Spring@Componentscanning, OSGi
- Approach:
Performance
- Interface dispatch: Calling through an interface is slower than direct call (~1-2 ns). JIT performs inline caching, but with > 2 types, a megamorphic transition occurs — drop to 10-20 ns
- Map lookup:
HashMap.get()— O(1), but with overhead on hashCode + boxing. For < 10 strategies, a simpleif-elsemay be faster - Memory overhead: Each additional class = metadata in Metaspace (~1-5 KB). With 1000+ strategies, this becomes noticeable
Production Experience
Real production scenario:
In a payment system, there was a PaymentProcessor with 40+ if-else branches for different payment methods. Adding a new method took 3-5 days (finding the spot, modifying, regression testing).
The team performed refactoring:
- Wrote 200+ characterization tests (without changing behavior)
- Extracted
PaymentMethodinterface - Created 40+ implementation classes (one per branch)
- Injected via Spring DI with automatic discovery
Result: New payment method — 2 hours (one class + test). Regression bugs dropped by 80%.
Monitoring and Diagnostics
How to detect the need for refactoring:
- Git analytics:
git log --follow -- <file>— file changes more than 2 times/month?git blame— different authors in one file?
- Metrics:
- Cyclomatic Complexity > 15
- Number of Branches > 5
- Regression frequency when adding new types
- Tools:
- SonarQube (Cognitive Complexity)
- Checkstyle (Method Length, Cyclomatic Complexity)
- ArchUnit (dependencies between layers)
Best Practices for Highload
- Hot Path: In performance-critical sections, avoid interfaces — use
switchon enum (JIT optimizes via tableswitch) - Cold Path: In business logic, use strategy — performance is not critical, flexibility is more important
- JIT-friendly: If there are > 3 strategies, consider
sealed interface+ pattern matching — JIT can optimize better
Summary for Senior
- Refactoring to OCP is an investment, not a dogma
- Always start with characterization tests
- Use Strangler Fig Pattern for legacy migration
- Remember about JIT megamorphic calls — in hot path,
switchmay be faster - Don’t refactor code that won’t be extended (YAGNI)
🎯 Interview Cheat Sheet
Must know:
- First step of OCP refactoring — cover code with tests (Characterization Tests for legacy)
- Strategies: Strategy (dynamic implementations), Visitor (multiple operations over hierarchy), sealed interface (closed set of types)
- Strangler Fig Pattern — gradual replacement of old code with new through a facade
- Safe refactoring: tests → abstraction → implementations → delegation → delete old code
- Refactoring to OCP — investment in reducing TCO, pays off only if the system will be extended
- In hot path,
switchon enum may be faster than strategy (JIT optimizes via tableswitch)
Common follow-up questions:
- How to refactor legacy without tests? — Characterization Tests: capture current behavior, then change
- Strategy vs sealed interface — which to choose? — Strategy for dynamic set, sealed for compile-time known set
- When is refactoring NOT needed? — Code is stable and doesn’t change, prototype, few branches (2-3) and no new ones expected
- What is Golden Master Testing? — Saved reference output of the system for comparison after refactoring
Red flags (DO NOT say):
- “Every if deserves its own class” (excessive decomposition)
- “Refactoring without tests is fine” (regression risk)
- “OCP needs to be applied to all code” (YAGNI: only to extension points)
Related topics:
- [[3. What is Open Closed principle]]
- [[19. How SOLID principles help when extending functionality]]
- [[18. How to refactor God Object]]
- [[9. Why do we need SOLID principles at all]]