📐 Розділ 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, 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 на поліморфну структуру:

  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

Дозволяє проводити масштабний рефакторинг у головній гілці (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). Перед виділенням стратегій необхідно:

  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) та пошкодження внутрішньої структури таблиці. Необхідний 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).

Пов’язані теми: