📐 Розділ 18 · Питання #18

Як рефакторити God Object (божественний об'єкт)

Такий клас порушує одразу всі принципи SOLID (у першу чергу SRP — у нього десятки причин для зміни).


🟢 Junior Level

God Object (Божественний об’єкт) або The Blob — це один із найпоширеніших антипатернів в ООП. Це гігантський клас, який «знає і робить абсолютно все»: концентрує в собі бізнес-логіку десятка підсистем, роботу з базою даних, валідацію, відправку повідомлень та генерацію звітів.

Такий клас порушує одразу всі принципи SOLID (у першу чергу SRP — у нього десятки причин для зміни).

Життєва аналогія

Уявіть одного співробітника в компанії, який одночасно працює генеральним директором, головним бухгалтером, системним адміністратором, кур’єром та прибиральником:

  • Якщо він захворіє — вся компанія миттєво зупиняється.
  • Черга до нього в кабінет розтягується на кілометри.
  • Він постійно плутає документи, тому що не може втримати в голові 50 різнорідних обов’язків.
  • Рішення: найняти профільних фахівців та розподілити завдання.

Симптоми God Object у Java-проєкті

  1. Розмір файлу перевищує 2000–5000 рядків коду.
  2. У назві класу присутні суфікси Manager, Processor, Handler, Engine або Controller.
  3. Конструктор приймає 15–20 залежностей або містить десятки мутабельних полів.
  4. Розробники бояться вносити навіть косметичні правки через непередбачувані регресійні баги.

Базовий 4-кроковий сценарій розпилу

Крок 1: [Тестовий каркас] ──> Покрити старий God Object тестами
Крок 2: [Групування]      ──> Виписати методи за бізнес-ролями (Кластери)
Крок 3: [Extract Class]   ──> Винести групу методів в окремий клас
Крок 4: [Фасад / Проксі]  ──> Старий клас тимчасово делегує виклики новому

🟡 Middle Level

Алгоритм декомпозиції: Від моноліту до чистих сервісів

Розпил багатотисячного класу не можна робити революційним методом («переписати все з нуля за вихідні»). Застосовується покроковий еволюційний рефакторинг за Мартіном Фаулером:

Крок 1. Характеризаційні тести (Characterization Tests)

Неможливо безпечно рефакторити те, у чому ви до кінця не впевнені. Пишуться тести «чорної скриньки» (Golden Master / Approval Tests), що фіксують поточну поведінку системи для ключових сценаріїв, включаючи специфічні граничні випадки.

Крок 2. Матриця зв’язності полів і методів (LCOM-аналіз)

Складається таблиця, що показує, які методи звертаються до яких полів:

           Поле: dbUrl  templateEngine  smsApiKey  taxRates
Метод:
calculateTax()     -           -            -         [X]   ──> Кластер 1: TaxCalculator
sendSms()          -           -           [X]         -    ──> Кластер 2: SmsNotificationService
saveOrder()       [X]          -            -          -    ──> Кластер 3: OrderRepository
renderPdf()        -          [X]           -          -    ──> Кластер 4: InvoicePdfRenderer

Крок 3. Введення інтерфейсів клієнтів (ISP)

Щоб код, що викликає, не залежав від усієї махіни, виділяються вузькі інтерфейси:

public interface OrderPersistence { void save(Order order); }
public interface InvoiceGenerator { byte[] generatePdf(Order order); }

Клас GodOrderManager тимчасово оголошується як implements OrderPersistence, InvoiceGenerator.

Крок 4. Патерн Strangler Fig (Фігове дерево / Задушення)

Нова функціональність виноситься в новий самостійний Spring-бін (TaxCalculator). Старий клас GodOrderManager інжектує цей новий бін і просто перенаправляє виклик:

@Service
public class GodOrderManager {
    // Новий винесений сервіс
    private final TaxCalculator taxCalculator;

    public BigDecimal calculateTax(Order order) {
        return taxCalculator.calculateTax(order); // ДЕЛЕГУВАННЯ
    }
}

Клієнти почергово переводяться на прямий виклик TaxCalculator. Коли в GodOrderManager не залишиться логіки, він безболісно видаляється.


🔴 Senior Level

Глибокі проблеми розпилу: Спільний стан і транзакції

1. Проблема Shared Mutable State: Введення Context Object

У God Object методи часто обмінюються даними через внутрішні приватні поля:

// Антипатерн усередині God Object:
private Order currentOrder;
private User currentUser;
private BigDecimal calculatedDiscount;

public void step1() { this.calculatedDiscount = ...; }
public void step2() { send(this.currentUser, this.calculatedDiscount); }

При розпилі на незалежні класи поля більше не можуть бути спільними. Рішення: Вводиться незмінний об’єкт контексту (Parameter Object / Context Object):

public record OrderProcessingContext(Order order, User customer, BigDecimal discount) {}

Контекст передається як параметр у конвеєр викликів (Pipeline Pattern).


2. Руйнування транзакційних меж (@Transactional)

У монолітному God Object метод processAll() був захищений єдиною транзакцією @Transactional:

// Усередині God Object:
@Transactional
public void process() {
    orderRepo.save();
    inventoryRepo.decrementStock();
    userRepo.updateLoyaltyPoints();
    billingRepo.charge();
}

Коли ці операції розносяться за незалежними мікросервісами або сервісами з різними джерелами даних:

  • Єдина транзакція БД більше не працює.
  • Застосовується патерн Saga (оркестрація компенсувальних транзакцій): якщо списання в billingRepo не вдалося, оркестратор викликає компенсувальні кроки inventoryRepo.rollbackStock().

3. Апаратний ефект: Усунення деградації CPU Cache та JIT

У God Object (4000+ рядків байткоду):

  • Методи перевищують ліміти інлайнінгу C2 (MaxInlineSize = 35 байткодів за замовчуванням). JIT не може оптимізувати гарячі ділянки.
  • Розділення God Object на невеликі класи (до 300 рядків) різко підвищує відсоток влучання в L1 Instruction Cache (I-Cache) процесора.
  • Рознесення гарячих змінних по різних об’єктах у купі повністю усуває взаємну інвалідацію кеш-ліній (False Sharing).

4 Tricky Questions

1. Що робити, якщо при спробі вирізати шматок коду з God Object виникає циклічна залежність між старим і новим класом?

Відповідь: Циклічна залежність (GodClass <---> ExtractedService) виникає, коли новому класу потрібні методи або поля, що залишилися в старому God-класі. Способи усунення:

  1. Dependency Inversion (DIP): Новий клас не повинен знати про старий God-клас. Він оголошує інтерфейс-колбек того, що йому потрібно, а старий клас реалізує цей контракт.
  2. Event-Driven Architecture: Заміна прямого виклику на публікацію доменної події. Виділений сервіс публікує OrderValidatedEvent, а старий клас слухає його через Spring @EventListener.
  3. Виділення третього спільного контексту: Спільно використовувані дані виносяться в незалежний OrderContext, що передається в обидва класи за посиланням.

2. Як розпиляти статичний God Object (наприклад, GlobalUtils на 3000 рядків static методів)?

Відповідь: Статичні методи неможливо мокати та впроваджувати через інтерфейси. План рефакторингу:

  1. Згрупувати статичні методи за доменами (StringUtils, DateUtils, SecurityUtils).
  2. Огорнути статичні виклики в екземпляри класів-сервісів з інтерфейсами (наприклад, створити бін SystemTimeProvider implements TimeProvider, який усередині викликає System.currentTimeMillis()).
  3. Поступово впроваджувати цей інтерфейс через DI у споживачів, витісняючи прямі статичні виклики.
  4. Оголосити застарілий клас @Deprecated із забороною на додавання нових методів через правила лінтера Checkstyle.

3. Чому рефакторинг God Object технічно безпечніший, ніж переписування системи з нуля («Greenfield Rewrite»)?

Відповідь: Переписування з нуля здається привабливим, але в 90% випадків закінчується провалом проєкту («Друга системна помилка» Фредеріка Брукса). Старий God Object за роки роботи накопичив у собі сотні прихованих багфіксів, обробок граничних умов інтеграцій та крайових сценаріїв законодавства, про які не пам’ятає жоден живий бізнес-аналітик. Поступовий рефакторинг через Strangler Fig Pattern дозволяє зберігати працюючу бізнес-логіку в продакшені щодня, перевіряючи зміни автоматизованими тестами, у той час як Greenfield-проєкт часто роками не може наздогнати функціонал старої системи.


4. Як розпиляти монолітний switch(orderType) на 30 варіантів усередині God Object, якщо варіанти залежать від внутрішніх полів?

Відповідь: Застосовується каталог Фаулера «Replace Conditional with Polymorphism»:

  1. Вводиться загальний інтерфейс стратегії OrderProcessingStrategy з методами boolean supports(OrderType type) та void execute(OrderProcessingContext ctx).
  2. Кожна гілка case вирізається в окремий клас із власними інжектованими залежностями.
  3. У God Object створюється реєстр Map<OrderType, OrderProcessingStrategy>, куди Spring автоматично складає всі зареєстровані стратегії.
  4. Монолітний блок switch замінюється на один рядок: strategies.get(type).execute(ctx).

🎯 Шпаргалка для інтерв’ю

Головні тези (30 секунд на відповідь):

  • Що це таке: Антипатерн класу, що знає і робить занадто багато (LCOM > 1.0, порушення SRP, OCP та ISP).
  • Головний патерн розпилу: Strangler Fig (Фігове дерево) — поступова заміна через фасад і делегування без Big Bang.
  • Інструменти декомпозиції:
    1. Characterization Tests (Golden Master) — фіксація поведінки legacy-коду до початку правок.
    2. LCOM4-аналіз — групування методів і полів за кластерами.
    3. Context Object — усунення спільного мутабельного стану.
    4. Replace Conditional with Polymorphism (Strategy Pattern) — розпил гігантських switch.
  • Продуктивність: Розпил God Object розвантажує L1 Instruction Cache та усуває вузьке горлечко єдиного монітора синхронізації.

Чого уникати (Red Flags):

  • ❌ Пропонувати «викинути старий клас і переписати заново з нуля» (високий ризик втрати крайової бізнес-логіки).
  • ❌ Починати рефакторинг без попереднього покриття тестами («рефакторинг наосліп»).
  • ❌ Замінювати один God-клас на один GodUtils зі статичними методами.

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