Вопрос 4 · Раздел 18

Как рефакторить код, нарушающий принцип Open-Closed?

Каждый новый тип клиента — правка существующего кода, риск сломать старую логику.

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Проблема: Код нарушает принцип Open-Closed, когда при добавлении новой функциональности вам приходится менять уже работающие методы. Чаще всего это проявляется в виде длинных цепочек if-else или switch по типу объекта.

Простой пример нарушения:

public class DiscountCalculator {
    public double calculateDiscount(String customerType, double amount) {
        if ("regular".equals(customerType)) {
            return amount * 0.05; // 5% скидка
        } else if ("vip".equals(customerType)) {
            return amount * 0.15; // 15% скидка
        } else if ("premium".equals(customerType)) {
            return amount * 0.25; // 25% скидка
        }
        return 0;
    }
}

Каждый новый тип клиента — правка существующего кода, риск сломать старую логику.

Базовый подход к рефакторингу:

Шаг 1: Выделите интерфейс

public interface DiscountStrategy {
    double calculateDiscount(double amount);
    String getCustomerType();
}

Шаг 2: Создайте отдельные классы для каждой ветки

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"; }
}

Шаг 3: Используйте Map для выбора стратегии

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);
    }
}

Теперь новый тип скидки — это один новый класс, без правок существующего кода.


🟡 Middle Level

Как распознать нарушение OCP

Основные признаки:

  1. Switch/If по типу: if (obj instanceof X), switch (type), switch (enum)
  2. God Methods: Методы на 100+ строк, которые “делают всё понемногу”
  3. Регрессионные баги: Добавление новой фичи ломает старую
  4. Merge Conflicts: Несколько разработчиков одновременно правят один и тот же файл

Пошаговый рефакторинг

До (нарушение OCP):

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;
            // Каждый новый канал — правка этого метода!
        }
    }
}

После (паттерн Strategy + Factory):

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);
    }
}

Безопасный рефакторинг: стратегия

  1. Покройте тестами существующий код (даже если он “кривой”)
  2. Создайте абстракцию (интерфейс)
  3. Реализуйте по одному классу для каждой ветки
  4. Замените switch/if на делегирование
  5. Удалите старый код
  6. Запустите тесты — поведение не должно измениться

Типичные ошибки при рефакторинге

  1. Ошибка: Рефакторинг без тестов Решение: Всегда начинайте с написания тестов на текущее поведение

  2. Ошибка: Создание абстракции “на всякий случай” Решение: Рефакторите только когда появилась реальная потребность в расширении

  3. Ошибка: Чрезмерная декомпозиция (каждый if — отдельный класс) Решение: Группируйте связанную логику; не каждый if заслуживает отдельного класса

  4. Ошибка: Забыли удалить старый код Решение: Мёртвый код путает следующих разработчиков — удаляйте смело

Когда рефакторинг НЕ нужен

  • Код стабильный и не меняется годами
  • Прототип / proof of concept
  • Веток мало (2-3) и новых не ожидается
  • Стоимость рефакторинга превышает стоимость поддержки

Как выбрать стратегию рефакторинга

  • Pattern Matching — для закрытых иерархий (sealed interface), когда набор типов известен
  • Strategy — когда реализации добавляются динамически (плагины, конфигурация)
  • Visitor — когда много разных операций над одной иерархией (налоги, отчёты, валидация)
  • SPI (ServiceLoader) — для библиотек, когда реализации подгружаются из classpath

SPI не поддерживает внедрение зависимостей — каждый класс должен иметь пустой конструктор.


🔴 Senior Level

Internal Implementation и Архитектура

Рефакторинг OCP — это не просто “заменить switch на стратегию”. Это перепроектирование точек расширения системы. На уровне Senior важно понимать:

Рефакторинг к OCP — это инвестиция в снижении TCO (Total Cost of Ownership). Она окупается только если система действительно будет расширяться.

Стратегии рефакторинга

1. Pattern Matching (Java 21+)

Вместо полного рефакторинга можно использовать современные возможности Java:

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: Это всё ещё нарушение OCP (нужно менять switch при новом типе), но компилятор предупредит о пропущенных случаях. Приемлемо для закрытых иерархий (sealed interface).

// Почему это нарушение OCP? При добавлении GoldCustomer вам придётся // вернуться в этот switch и добавить новый case. Вы МОДИФИЦИРУЕТЕ // существующий код, а не расширяете.

2. Double Dispatch (Visitor Pattern)

Для сложных иерархий с несколькими операциями:

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)

Для truly extensible систем:

public interface DiscountProvider {
    boolean supports(String customerType);
    double calculate(double amount);
}

// В META-INF/services/com.example.DiscountProvider
// registrieren Sie alle Implementierungen

Архитектурные Trade-offs

Полный рефакторинг к OCP:

  • ✅ Плюсы: Чистая архитектура, легкое расширение, минимум регрессий
  • ❌ Минусы: Высокая стоимость рефакторинга, риск regressions при миграции, больше классов

Частичный рефакторинг (sealed types, enums):

  • ✅ Плюсы: Быстро, компилятор помогает, минимум boilerplate
  • ❌ Минусы: При добавлении типа всё равно нужно менять код

Без рефакторинга:

  • ✅ Плюсы: Ноль затрат сейчас
  • ❌ Минусы: Накопление технического долга, замедление разработки в будущем

Edge Cases

  1. Legacy Code без тестов: Как рефакторить?
    • Approach: Characterization Tests — напишите тесты, которые фиксируют текущее поведение, даже если оно “кривое”
    • Tool: Approval Tests, Golden Master Testing

Strangler Fig Pattern — постепенная замена старой системы новой через фасад. Как лиана оплетает дерево: новый код постепенно забирает функциональность у старого.

Characterization Tests — тесты, фиксирующие текущее поведение legacy-кода. Вы не знаете, что «правильно», но знаете, что «сейчас так работает».

Golden Master — сохранённый эталонный вывод системы для сравнения после рефакторинга.

  1. Cross-cutting Concerns: Когда switch влияет на 5+ модулей
    • Approach: Strangler Fig Pattern — постепенно заменяйте старый код новым, маршрутизируя через фасад
  2. Runtime Discovery: Когда новые реализации подгружаются динамически (плагины)
    • Approach: ServiceLoader, Spring @Component scanning, OSGi

Производительность

  • Interface dispatch: Вызов через интерфейс медленнее прямого вызова (~1-2 ns). JIT делает inline caching, но при > 2 типов происходит megamorphic transition — падение до 10-20 ns
  • Map lookup: HashMap.get() — O(1), но с оверхедом на hashCode + boxing. Для < 10 стратегий можно использовать простой if-else — он будет быстрее
  • Memory overhead: Каждый дополнительный класс = метаданные в Metaspace (~1-5 KB). При 1000+ стратегиях это становится заметным

Production Experience

Реальный сценарий из продакшена:

В платёжной системе был PaymentProcessor с 40+ ветками if-else для разных способов оплаты. Добавление нового метода занимало 3-5 дней (поиск места, правка, регрессионное тестирование).

Команда провела рефакторинг:

  1. Написали 200+ characterization tests (без изменения поведения)
  2. Выделили PaymentMethod интерфейс
  3. Создали 40+ классов-реализаций (по одному на каждую ветку)
  4. Внедрили через Spring DI с автоматическим обнаружением

Результат: Новый метод оплаты — 2 часа (один класс + тест). Количество регрессионных багов упало на 80%.

Monitoring и диагностика

Как обнаружить необходимость рефакторинга:

  1. Git-аналитика:
    • git log --follow -- <file> — файл меняется чаще 2 раз/месяц?
    • git blame — разные авторы в одном файле?
  2. Метрики:
    • Cyclomatic Complexity > 15
    • Number of Branches > 5
    • Частота регрессий при добавлении новых типов
  3. Инструменты:
    • SonarQube (Cognitive Complexity)
    • Checkstyle (Method Length, Cyclomatic Complexity)
    • ArchUnit (зависимости между слоями)

Best Practices для Highload

  • Hot Path: В критичных к производительности участках избегайте интерфейсов — используйте switch по enum (JIT оптимизирует через tableswitch)
  • Cold Path: В бизнес-логике используйте стратегию — производительность не критична, гибкость важнее
  • JIT-friendly: Если стратегий > 3, рассмотрите sealed interface + pattern matching — JIT сможет лучше оптимизировать

Резюме для Senior

  • Рефакторинг к OCP — это инвестиция, а не догма
  • Всегда начинайте с характеризационных тестов
  • Используйте Strangler Fig Pattern для миграции legacy
  • Помните про JIT megamorphic call — в hot path switch может быть быстрее
  • Не рефакторите код, который не будет расширяться (YAGNI)

🎯 Шпаргалка для интервью

Обязательно знать:

  • Первый шаг рефакторинга к OCP — покрыть код тестами (Characterization Tests для legacy)
  • Стратегии: Strategy (динамические реализации), Visitor (множество операций над иерархией), sealed interface (закрытый набор типов)
  • Strangler Fig Pattern — постепенная замена старого кода новым через фасад
  • Безопасный рефакторинг: тесты → абстракция → реализации → делегирование → удаление старого кода
  • Рефакторинг к OCP — инвестиция в снижение TCO, окупается только если система будет расширяться
  • В hot path switch по enum может быть быстрее стратегии (JIT оптимизирует через tableswitch)

Частые уточняющие вопросы:

  • Как рефакторить legacy без тестов? — Characterization Tests: фиксируем текущее поведение, потом меняем
  • Strategy vs sealed interface — что выбрать? — Strategy для динамического набора, sealed для известного на этапе компиляции
  • Когда рефакторинг НЕ нужен? — Код стабилен и не меняется, прототип, веток мало (2-3) и новых не ожидается
  • Что такое Golden Master Testing? — Сохранённый эталонный вывод системы для сравнения после рефакторинга

Красные флаги (НЕ говорить):

  • “Каждый if deserves свой собственный класс” (чрезмерная декомпозиция)
  • “Рефакторинг без тестов — нормально” (риск регрессий)
  • “OCP нужно применять ко всему коду” (YAGNI: только к точкам расширения)

Связанные темы:

  • [[3. Что такое принцип Open_Closed]]
  • [[19. Как SOLID принципы помогают при расширении функционала]]
  • [[18. Как рефакторить God Object (божественный объект)]]
  • [[9. Зачем вообще нужны принципы SOLID]]