Как рефакторить код, нарушающий принцип 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);
}
}
}
Опасности такого кода:
- При добавлении «Экспресс-доставки дроном» нужно редактировать этот метод.
- Легко случайно изменить формулу для обычной почты (
POST) или стереть граничные условия. - Класс объединяет в себе формулы всех видов доставки (нарушение 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
Паттерны рефакторинга по Мартину Фаулеру
- Replace Conditional with Polymorphism (Замена условных операторов полиморфизмом):
- Применяется, когда в разных методах класса повторяются одинаковые
switch/ifпо одному и тому же дискриминатору типа. - Поведение выносится в подклассы или независимые стратегии.
- Применяется, когда в разных методах класса повторяются одинаковые
- Replace Type Code with Strategy / State:
- Если поведение объекта меняется в зависимости от его статуса/состояния (например,
OrderStatus.NEW,PAID,SHIPPED), объект делегирует логику экземпляру состояния (OrderState).
- Если поведение объекта меняется в зависимости от его статуса/состояния (например,
- Factory Method / Strategy Registry:
- Разделение ответственности: калькулятор считает, а фабрика инкапсулирует создание или поиск стратегии (быстрый lookup через
Map<String, DeliveryStrategy>).
- Разделение ответственности: калькулятор считает, а фабрика инкапсулирует создание или поиск стратегии (быстрый lookup через
Рефакторинг в 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 в полиморфную структуру:
- Тестовый каркас (Safety Net): напишите модульные тесты на все существующие ветки
if-elseдо внесения любых изменений. - Шаг за шагом (Strangler Pattern внутри метода):
- Выделите интерфейс.
- Реализуйте стратегию для одной ветки.
- Замените эту одну ветку на вызов стратегии, оставив остальные ветки во временном
else. - Прогоните тесты.
- Повторите для остальных веток.
- Устранение устаревшего кода: удалите старые ветки, как только все стратегии покрыли варианты.
🔴 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). Перед выделением стратегий необходимо:
- Применить рефакторинг Extract Parameter Object / Command Pattern: объединить контекст выполнения в отдельный объект состояния (
ExecutionContextилиOrderProcessingContext). - Передавать этот контекст в сигнатуру метода интерфейса стратегии (
void execute(ExecutionContext context)). - Если стратегии должны передавать данные друг другу, заменить простой выбор стратегии на паттерн 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).
Связанные темы: