Question 4 · Section 18

How to Refactor Code that Violates Open-Closed Principle?

Each new customer type means modifying existing code, risking breaking old logic.

Language versions: English Russian Ukrainian

🟢 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:

  1. Switch/If by type: if (obj instanceof X), switch (type), switch (enum)
  2. God Methods: Methods of 100+ lines that “do a bit of everything”
  3. Regression bugs: Adding a new feature breaks old functionality
  4. 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

  1. Cover with tests the existing code (even if it’s “ugly”)
  2. Create an abstraction (interface)
  3. Implement one by one class for each branch
  4. Replace switch/if with delegation
  5. Delete the old code
  6. Run tests — behavior should not change

Common Refactoring Mistakes

  1. Mistake: Refactoring without tests Solution: Always start by writing tests for current behavior

  2. Mistake: Creating abstraction “just in case” Solution: Refactor only when there’s a real need for extension

  3. Mistake: Excessive decomposition (each if — a separate class) Solution: Group related logic; not every if deserves its own class

  4. 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

  1. 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.

  1. Cross-cutting Concerns: When switch affects 5+ modules
    • Approach: Strangler Fig Pattern — gradually replace old code with new, routing through a facade
  2. Runtime Discovery: When new implementations are loaded dynamically (plugins)
    • Approach: ServiceLoader, Spring @Component scanning, OSGi

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 simple if-else may 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:

  1. Wrote 200+ characterization tests (without changing behavior)
  2. Extracted PaymentMethod interface
  3. Created 40+ implementation classes (one per branch)
  4. 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:

  1. Git analytics:
    • git log --follow -- <file> — file changes more than 2 times/month?
    • git blame — different authors in one file?
  2. Metrics:
    • Cyclomatic Complexity > 15
    • Number of Branches > 5
    • Regression frequency when adding new types
  3. 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 switch on 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, switch may 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, switch on 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]]