Як рефакторити код, що порушує принцип Open-Closed?
Кожен новий тип клієнта — зміна існуючого коду, ризик зламати стару логіку.
🟢 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
Основні ознаки:
- Switch/If за типом:
if (obj instanceof X),switch (type),switch (enum) - God Methods: Методи на 100+ рядків, які “роблять все потроху”
- Регресійні баги: Додавання нової фічі ламає стару
- 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 логіка */ }
}
public class SmsChannel implements NotificationChannel {
@Override
public String getType() { return "sms"; }
@Override
public void send(String message) { /* sms логіка */ }
}
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);
}
}
Безпечний рефакторинг: стратегія
- Покрийте тестами існуючий код (навіть якщо він “кривий”)
- Створіть абстракцію (інтерфейс)
- Реалізуйте по одному класу для кожної гілки
- Замініть switch/if на делегування
- Видаліть старий код
- Запустіть тести — поведінка не повинна змінитися
Типові помилки при рефакторингу
-
Помилка: Рефакторинг без тестів Рішення: Завжди починайте з написання тестів на поточну поведінку
-
Помилка: Створення абстракції “на всякий випадок” Рішення: Рефакторте лише коли з’явилася реальна потреба у розширенні
-
Помилка: Надмірна декомпозиція (кожен if — окремий клас) Рішення: Групуйте пов’язану логіку; не кожен
ifзаслуговує на окремий клас -
Помилка: Забули видалити старий код Рішення: Мертвий код бентежить наступних розробників — видаляйте сміливо
Коли рефакторинг НЕ потрібен
- Код стабільний і не змінюється роками
- Прототип / 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
// зареєструйте всі реалізації
Архітектурні Trade-offs
Повний рефакторинг до OCP:
- ✅ Плюси: Чиста архітектура, легке розширення, мінімум регресій
- ❌ Мінуси: Висока вартість рефакторингу, риск regressions при міграції, більше класів
Частковий рефакторинг (sealed types, enums):
- ✅ Плюси: Швидко, компілятор допомагає, мінімум boilerplate
- ❌ Мінуси: При додаванні типу все одно потрібно змінювати код
Без рефакторингу:
- ✅ Плюси: Нуль витрат зараз
- ❌ Мінуси: Накопичення технічного боргу, сповільнення розробки в майбутньому
Edge Cases
- Legacy Code без тестів: Як рефакторити?
- Approach: Characterization Tests — напишіть тести, які фіксують поточну поведінку, навіть якщо вона “крива”
- Tool: Approval Tests, Golden Master Testing
Strangler Fig Pattern — поступова заміна старої системи новою через фасад. Як ліана обплітає дерево: новий код поступово забирає функціональність у старого.
Characterization Tests — тести, що фіксують поточну поведінку legacy-коду. Ви не знаєте, що «правильно», але знаєте, що «зараз так працює».
Golden Master — збережений еталонний вивід системи для порівняння після рефакторингу.
- Cross-cutting Concerns: Коли switch впливає на 5+ модулів
- Approach: Strangler Fig Pattern — поступово замінюйте старий код новим, маршрутизуючи через фасад
- Runtime Discovery: Коли нові реалізації підвантажуються динамічно (плагіни)
- Approach:
ServiceLoader, Spring@Componentscanning, OSGi
- Approach:
Продуктивність
- 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 днів (пошук місця, зміна, регресійне тестування).
Команда провела рефакторинг:
- Написали 200+ characterization tests (без зміни поведінки)
- Виділили
PaymentMethodінтерфейс - Створили 40+ класів-реалізацій (по одному на кожну гілку)
- Впровадили через Spring DI з автоматичним виявленням
Результат: Новий метод оплати — 2 години (один клас + тест). Кількість регресійних багів впала на 80%.
Monitoring та діагностика
Як виявити необхідність рефакторингу:
- Git-аналітика:
git log --follow -- <file>— файл змінюється частіше 2 разів/місяць?git blame— різні автори в одному файлі?
- Метрики:
- Cyclomatic Complexity > 15
- Number of Branches > 5
- Частота регресій при додаванні нових типів
- Інструменти:
- 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]]