Питання 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 логіка */ }
}

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

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

  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
// зареєструйте всі реалізації

Архітектурні 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]]