Як рефакторити код, що порушує принцип 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, APPLE_PAY, CRYPTO
}
public interface PaymentProcessor {
PaymentType getSupportedType();
PaymentResult process(PaymentRequest request);
}
@Component
public class CardPaymentProcessor implements PaymentProcessor {
@Override
public PaymentType getSupportedType() {
return PaymentType.CARD;
}
@Override
public PaymentResult process(PaymentRequest request) {
// інтеграція з еквайрингом карток
return new PaymentResult(Status.SUCCESS, "CARD-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
Дозволяє проводити масштабний рефакторинг у головній гілці (trunk) без довгоживучих feature-гілок:
Крок 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) та пошкодження внутрішньої структури таблиці. Необхідний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, що зводить зачеплення системи до мінімуму.
🎯 Шпаргалка для інтерв’ю
Головні тези:
- Головний патерн: Replace Conditional with Polymorphism (патерн Strategy + Registry/Factory).
- Схема рефакторингу: Тести → Інтерфейс → Класи-стратегії → Заміна гілок на делегування через
MapабоList→ Видалення старого коду. - У Spring/Enterprise: Інжекція
List<Strategy>у конструктор та складання вMapза ключем типу. - Безпека рефакторингу: Characterization Tests (Golden Master) для фіксації поточної поведінки legacy-коду; Branch by Abstraction та Feature Toggles під час викатки в продакшен.
Чого уникати (Червоні прапорці):
- ❌ Починати рефакторити монолітний
if-elseбез попереднього покриття тестами. - ❌ Рефакторити прості умови з 2 стабільних гілок, які ніколи не розширюватимуться (порушення YAGNI).
- ❌ Створювати на кожну гілку клас, але передавати туди
Objectабо робити приведення типів (кастинг) усередині стратегії (порушення LSP).