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

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

Код нарушает принцип Open-Closed (OCP), если при добавлении каждого нового бизнес-требования, нового типа данных или формата вам приходится открывать уже оттестированный класс и...


🟢 Junior Level

Что значит нарушение OCP на практике

Код нарушает принцип Open-Closed (OCP), если при добавлении каждого нового бизнес-требования, нового типа данных или формата вам приходится открывать уже оттестированный класс и вручную дописывать в него новые ветки if-else или switch.

Типичный пример «до рефакторинга»

Представим калькулятор стоимости доставки, в котором способ доставки зашит в виде условных операторов:

public class DeliveryCostCalculator {
    public BigDecimal calculateCost(String deliveryType, double weightKg, double distanceKm) {
        if ("COURIER".equalsIgnoreCase(deliveryType)) {
            return BigDecimal.valueOf(weightKg * 10.0 + distanceKm * 5.0 + 150.0);
        } else if ("POST".equalsIgnoreCase(deliveryType)) {
            return BigDecimal.valueOf(weightKg * 5.0 + distanceKm * 2.0 + 50.0);
        } else if ("PICKUP_POINT".equalsIgnoreCase(deliveryType)) {
            return BigDecimal.valueOf(distanceKm > 10 ? 100.0 : 0.0);
        } else {
            throw new IllegalArgumentException("Unsupported delivery type: " + deliveryType);
        }
    }
}

Опасности такого кода:

  1. При добавлении «Экспресс-доставки дроном» нужно редактировать этот метод.
  2. Легко случайно изменить формулу для обычной почты (POST) или стереть граничные условия.
  3. Класс объединяет в себе формулы всех видов доставки (нарушение Single Responsibility).

Пошаговый план рефакторинга для начинающих

Рефакторинг выполняется по классическому каталогу Мартина Фаулера (Replace Conditional with Polymorphism):

Шаг 1. Выделяем интерфейс стратегии (контракт)

Определяем метод, инкапсулирующий изменяемое поведение:

public interface DeliveryStrategy {
    boolean supports(String deliveryType);
    BigDecimal calculate(double weightKg, double distanceKm);
}

Шаг 2. Создаем отдельные классы под каждую ветку

Каждая ветка if переносится в собственный класс:

public class CourierDeliveryStrategy implements DeliveryStrategy {
    @Override
    public boolean supports(String deliveryType) {
        return "COURIER".equalsIgnoreCase(deliveryType);
    }

    @Override
    public BigDecimal calculate(double weightKg, double distanceKm) {
        return BigDecimal.valueOf(weightKg * 10.0 + distanceKm * 5.0 + 150.0);
    }
}

public class PostDeliveryStrategy implements DeliveryStrategy {
    @Override
    public boolean supports(String deliveryType) {
        return "POST".equalsIgnoreCase(deliveryType);
    }

    @Override
    public BigDecimal calculate(double weightKg, double distanceKm) {
        return BigDecimal.valueOf(weightKg * 5.0 + distanceKm * 2.0 + 50.0);
    }
}

Шаг 3. Делегируем выполнение через реестр стратегий

Сервис больше не содержит формул, он лишь выбирает подходящую стратегию:

public class DeliveryCostCalculator {
    private final List<DeliveryStrategy> strategies;

    public DeliveryCostCalculator(List<DeliveryStrategy> strategies) {
        this.strategies = List.copyOf(strategies);
    }

    public BigDecimal calculateCost(String deliveryType, double weightKg, double distanceKm) {
        return strategies.stream()
                .filter(s -> s.supports(deliveryType))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Unsupported delivery type: " + deliveryType))
                .calculate(weightKg, distanceKm);
    }
}

Теперь добавление нового способа доставки — это создание одного нового класса без модификации DeliveryCostCalculator.


🟡 Middle Level

Паттерны рефакторинга по Мартину Фаулеру

  1. Replace Conditional with Polymorphism (Замена условных операторов полиморфизмом):
    • Применяется, когда в разных методах класса повторяются одинаковые switch/if по одному и тому же дискриминатору типа.
    • Поведение выносится в подклассы или независимые стратегии.
  2. Replace Type Code with Strategy / State:
    • Если поведение объекта меняется в зависимости от его статуса/состояния (например, OrderStatus.NEW, PAID, SHIPPED), объект делегирует логику экземпляру состояния (OrderState).
  3. Factory Method / Strategy Registry:
    • Разделение ответственности: калькулятор считает, а фабрика инкапсулирует создание или поиск стратегии (быстрый lookup через Map<String, DeliveryStrategy>).

Рефакторинг в Spring Boot приложении

В реальных Enterprise-проектах стратегии регистрируются как Spring-бины. Использование Map<Type, Strategy> обеспечивает быстрый доступ за $O(1)$:

public enum PaymentType {
    CARD, SBP, SBER_PAY, CRYPTO
}

public interface PaymentProcessor {
    PaymentType getSupportedType();
    PaymentResult process(PaymentRequest request);
}

@Component
public class SbpPaymentProcessor implements PaymentProcessor {
    @Override
    public PaymentType getSupportedType() {
        return PaymentType.SBP;
    }

    @Override
    public PaymentResult process(PaymentRequest request) {
        // интеграция с СБП API
        return new PaymentResult(Status.SUCCESS, "SBP-TX-101");
    }
}

@Service
public class PaymentRoutingService {
    private final Map<PaymentType, PaymentProcessor> processorRegistry;

    // Spring собирает все бины PaymentProcessor в List и передает в конструктор
    public PaymentRoutingService(List<PaymentProcessor> processors) {
        this.processorRegistry = processors.stream()
                .collect(Collectors.toUnmodifiableMap(
                        PaymentProcessor::getSupportedType,
                        Function.identity()
                ));
    }

    public PaymentResult executePayment(PaymentRequest request) {
        PaymentProcessor processor = processorRegistry.get(request.paymentType());
        if (processor == null) {
            throw new UnsupportedPaymentTypeException(request.paymentType());
        }
        return processor.process(request);
    }
}

Чек-лист безопасного проведения рефакторинга

Чтобы не сломать прод в процессе преобразования монолитного switch в полиморфную структуру:

  1. Тестовый каркас (Safety Net): напишите модульные тесты на все существующие ветки if-else до внесения любых изменений.
  2. Шаг за шагом (Strangler Pattern внутри метода):
    • Выделите интерфейс.
    • Реализуйте стратегию для одной ветки.
    • Замените эту одну ветку на вызов стратегии, оставив остальные ветки во временном else.
    • Прогоните тесты.
    • Повторите для остальных веток.
  3. Устранение устаревшего кода: удалите старые ветки, как только все стратегии покрыли варианты.

🔴 Senior Level

Промышленный рефакторинг: Branch by Abstraction и Characterization Tests

В высоконагруженных системах (Billing, Core Banking) рефакторить монолитный блок условных операторов «наживую» в одном Pull Request недопустимо из-за риска скрытых побочных эффектов.

1. Характеризационные тесты (Characterization Tests / Golden Master)

Если документация устарела, а код запутан:

  • Собирается дамп реальных запросов и ответов из продакшена (10 000+ транзакций).
  • Создается параметризованный тест (Golden Master Testing), прогоняющий дамп через старый код и сохраняющий контрольные суммы результатов.
  • В ходе рефакторинга новый код сверяется с Golden Master до совпадения 100% кейсов, включая исторические баги, на которые уже рассчитывают клиенты!

2. Техника Branch by Abstraction

Позволяет проводить масштабный рефакторинг в транке без долгоживущих веток:

Шаг 1: [Клиенты] ──> [Старая реализация с 20 if-else]

Шаг 2: [Клиенты] ──> [Интерфейс-Абстракция] ──> [Старая реализация]
                                             └──> [Новая цепочка Стратегий]
                                                    (скрыта Feature Toggle)

Шаг 3: Feature Toggle переключает трафик (1% -> 10% -> 100%) на новую реализацию.

Шаг 4: Удаление старого монолитного кода и интерфейса-переключателя.

Анализ производительности: Switch vs Polymorphic Dispatch

При принятии решения о рефакторинге Senior должен оценивать профиль нагрузки:

Критерий switch по int / enum Polymorphic Strategy (Интерфейс + Map)
Машинные инструкции Байткод tableswitch (O(1), таблица смещений) или lookupswitch (O(log N), бинарный поиск). invokeinterface, поиск в таблице itable + косвенный переход по указателю.
JIT Inlining Ветки часто инлайнятся C2 целиком в один непрерывный машинный блок кода. Не инлайнится при >2 активных реализациях (Megamorphic call site).
Аллокация памяти Ноль аллокаций. Все переменные на регистрах или стеке. Вызов через Map.get() создает нагрузку на кэш L1/L2 процессора, возможен boxing примитивов.
Расширяемость (OCP) Низкая: правка исходного файла, перекомпиляция всего модуля. Высокая: изоляция модулей, динамическая загрузка плагинов (SPI / Spring).

Правило выбора: В Hot-Path цикле обработки 1 000 000 пакетов в секунду (High-Frequency Trading, парсинг сетевых протоколов) переход от switch к OCP-стратегии ухудшит задержку (Latency). В 98% обычного бизнес-кода (Web, REST API, обработка заказов) накладные расходы интерфейса составляют доли наносекунд, и OCP дает колоссальный выигрыш в поддерживаемости.


4 Tricky Questions

1. Что делать, если ветки монолитного if-else используют общие локальные переменные и мутируют разделяемое состояние?

Ответ: Это классический симптом высокой связности (High Coupling). Перед выделением стратегий необходимо:

  1. Применить рефакторинг Extract Parameter Object / Command Pattern: объединить контекст выполнения в отдельный объект состояния (ExecutionContext или OrderProcessingContext).
  2. Передавать этот контекст в сигнатуру метода интерфейса стратегии (void execute(ExecutionContext context)).
  3. Если стратегии должны передавать данные друг другу, заменить простой выбор стратегии на паттерн Chain of Responsibility (Цепочка обязанностей) или Pipeline Pattern, где каждый шаг принимает неизменяемый (immutable) контекст и возвращает его обновленную версию.

2. Приведет ли замена switch на Map<Type, Strategy> к утечке памяти или проблемам с многопоточностью?

Ответ:

  • Если Map заполняется один раз при старте приложения (например, в конструкторе Spring-сервиса через Map.copyOf(...) или Collections.unmodifiableMap(...)), потокобезопасность гарантирована без синхронизации (safe publication immutable-коллекции).
  • Если стратегии регистрируются динамически в runtime (плагины), использование обычного HashMap приведет к состоянию гонки (Race Condition) и возможному зацикливанию/порче таблицы в старых версиях Java. Требуется ConcurrentHashMap или CopyOnWriteArrayList.
  • Утечка памяти возможна, если динамически загружаемые плагины создают свои ClassLoader, а ссылки на стратегии зависают в статическом реестре корневого загрузчика (Metaspace ClassLoader Leak).

3. Всегда ли рефакторинг к полиморфизму лучше, чем switch по enum с абстрактными методами?

Ответ: Нет. Java позволяет объявить абстрактный метод прямо в enum, реализуя его для каждого элемента:

public enum Operation {
    PLUS { double apply(double a, double b) { return a + b; } },
    MINUS { double apply(double a, double b) { return a - b; } };
    abstract double apply(double a, double b);
}

Если набор вариантов заведомо фиксирован предметной областью (например, базовые математические операторы, дни недели, стороны света), полиморфный enum гораздо компактнее, быстрее и не требует раздувания кодовой базы десятками отдельных файлов классов.

4. Как быть, если стратегия требует внешних зависимостей, специфичных только для нее (например, интеграционный клиент банка)?

Ответ: В монолитном коде сервис вынужден инжектировать клиенты всех возможных банков, превращаясь в “божественный объект” (God Object). При рефакторинге к OCP каждая стратегия становится самостоятельным Spring-бином (@Component) и через конструктор инжектирует только свои специфичные зависимости. Роутер (PaymentRoutingService) не знает ни о каких банковских клиентах — он зависит только от единого интерфейса PaymentProcessor, что снижает связность системы до минимума.


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

Главные тезисы (30 секунд на ответ):

  • Главный паттерн: Replace Conditional with Polymorphism (паттерн Strategy + Registry/Factory).
  • Схема рефакторинга: Тесты → Интерфейс → Классы-стратегии → Замена веток на делегирование через Map или List → Удаление старого кода.
  • В Enterprise/Spring: Инжекция List<Strategy> в конструктор и сборка в Map по ключу типа.
  • Безопасность рефакторинга: Characterization Tests (Golden Master) для фиксации поведения legacy-кода; Branch by Abstraction и Feature Toggles при выкатке в прод.

Чего избегать (Red Flags):

  • ❌ Начинать рефакторить монолитный if-else без предварительного покрытия тестами.
  • ❌ Рефакторить тривиальные условия из 2 стабильных веток, которые никогда не будут расширяться (нарушение YAGNI).
  • ❌ Создавать на каждую ветку класс, но передавать туда Object или кастовать типы внутри стратегии (нарушение LSP).

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