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

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

Уявіть один величезний швейцарський ніж з 200 інструментами. Щоб знайти потрібну викрутку, потрібно перебрати все: від відкривачки до пили. Краще мати набір з 10 маленьких ножів...

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Просте визначення

God Object — це клас, який «робить все». Він містить сотні методів, десятки полів і відповідає за множество незв’язаних завдань. Це антипатерн, бо такий клас неможливо читати, тестувати і підтримувати.

Аналогія

Уявіть один величезний швейцарський ніж з 200 інструментами. Щоб знайти потрібну викрутку, потрібно перебрати все: від відкривачки до пили. Краще мати набір з 10 маленьких ножів, кожен для свого завдання.

Простий приклад

// ПОГАНО: God Object — робить ВСЕ
public class UserManager {
    public void createUser() { ... }
    public void deleteUser() { ... }
    public void sendEmail() { ... }
    public void generateReport() { ... }
    public void connectToDatabase() { ... }
    public void validateInput() { ... }
    public void logActivity() { ... }
    public void exportToPdf() { ... }
    // ще 50 методів...
}

// ДОБРЕ: розділення за відповідальностями
public class UserService { public void createUser() { ... } }
public class EmailService { public void sendEmail() { ... } }
public class ReportService { public void generateReport() { ... } }
public class Logger { public void logActivity() { ... } }

Коли використовувати (точніше, коли НЕ залишати God Object)

  • Коли клас має > 30 методів — пора задуматися.
  • Коли в класі є методи для БД, UI, email і бізнес-логіки одночасно.
  • Коли ви не можете назвати одним реченням, що робить цей клас.

🟡 Middle Level

Як це працює всередині

God Object росте еволюційно. Ніхто не створює його навмисно — він з’являється через поступову ентропію:

  1. Початок: маленький сервіс з 3 методами.
  2. Ріст: «Просто додам ще один метод сюди, навіщо створювати новий клас?»
  3. Криза: 200 методів, 50 полів, час компіляції росте, тести займають хвилини.

Метрика LCOM (Lack of Cohesion in Methods — “нестача зв’язності методів”, показує, наскільки методи класу не пов’язані один з одним) формалізує проблему:

  • LCOM > 1.0 означає, що методи класу не пов’язані один з одним — клас кандидат на розділення.

Алгоритм декомпозиції

Крок 1: Аналіз відповідальностей (Feature Analysis)

Згрупуйте методи і поля за бізнес-смислом, не чіпаючи код:

Група Методи Поля Новий клас
CRUD користувачів createUser, deleteUser, updateUser userRepository UserService
Email sendEmail, sendWelcomeEmail, sendResetPassword emailClient, emailTemplate EmailService
Логування logLogin, logError, logAudit logWriter AuditLogger
Звіти generateReport, exportToPdf, exportToCsv reportTemplate ReportService

Крок 2: Витягнення інтерфейсів (ISP)

Якщо God Object використовується різними клієнтами для різних цілей, виділіть вузькі інтерфейси:

public interface UserReader { User findById(Long id); }
public interface UserWriter { void save(User user); }
public interface UserNotifier { void notifyUser(Long userId, String message); }

Кожен клієнт залежить тільки від потрібного інтерфейсу, а не від всієї махини.

Крок 3: Виділення класів (Extract Class) через делегування

Спочатку зробіть God Object фасадом:

public class UserManager { // колишній God Object
    private final UserService userService = new UserService();
    private final EmailService emailService = new EmailService();

    // Делегує — поки клієнти не змінюємо
    public void createUser(User u) { userService.create(u); }
    public void sendEmail(String to, String msg) { emailService.send(to, msg); }
}

Потім поступово переводите клієнтів на пряме використання нових класів.

Типові помилки при рефакторингу

Помилка Рішення
Спільний стан: 50 полів використовуються всіма методами впереміш Передавайте стан як параметри методів або створіть Context Object
Транзакційна цілісність: God Object забезпечував атомарність через synchronized Після розділення потрібен координатор транзакцій або Saga-патерн (шаблон для розподілених транзакцій: якщо крок не вдався, виконується компенсувальна дія для скасування попередніх кроків)
Класи-паразити: Виділили клас, але він все одно залежить від 80% полів God Object Поглибте аналіз — можливо, виділення було занадто поверхневим
Big Ball of Mud (Великий ком бруду — система без чіткої архітектури, де все переплетено): Все зв’язане з усім, немає чітких меж Почніть з тестів — якщо метод не можна протестувати без 20 моків, це точка розрізу

Порівняння підходів

Підхід Плюси Мінуси Коли застосовувати
Extract Class Чітке розділення, кожен клас — одна відповідальність Тимчасовий overhead, потрібно оновлювати всіх клієнтів Основна стратегія
Facade Швидко ховає складність, клієнти не ламаються Тимчасова міра — Facade все одно знає все Проміжний етап
Mediator Розв’язує методи, які активно спілкувалися один з одним Додає ще один клас Коли є щільна внутрішня комунікація
Strategy Замінює умовну логіку (if/switch) на поліморфізм Більше класів Коли God Object містить 50+ гілок if/else

Коли НЕ рефакторити

  • Легасі без тестів — спочатку напишіть characterization tests, потім рефакторте. Без тестів ви не дізнаєтесь, чи зламали щось.
  • Проект в end-of-life — якщо через місяць все буде видалено, investment не окупиться.
  • God DTO — якщо «God Object» — це просто DTO зі 100 полями без поведінки, це не проблема дизайну, це проблема доменної моделі (вирішується через Bounded Context).

🔴 Senior Level

Глибока внутрішня реалізація

JVM-рівень: Memory Layout God Object

Великий клас з 50 полями створює об’єкт з серйозним overhead:

Object Header:  12 байт (mark word + class pointer)
50 полів:       50 × 4 байта (reference) = 200 байт
Вирівнювання:   ~4 байта (padding до 8-байтової границі)
Разом:          ~216 байт на екземпляр

Після розділення на 10 класів по 5 полів:

10 × (12 header + 20 refs + 0 padding) = 10 × 32 = 320 байт
+ 9 × 4 байта (посилання між об'єктами) = 36 байт
Разом: ~356 байт

Overhead: ~140 байт на екземпляр (65% більше). В абсолютних числах — negligible для бізнес-додатків. Але в highload з мільйонами об’єктів — 140 МБ на 1 млн екземплярів.

Bytecode і JIT

God Object з 200 методами:

  • Constant pool росте — більше завантаження класу.
  • JIT не може ефективно inline’ити методи з величезного класу — регістри CPU переповнюються, JIT fallback’ить в interpreter.
  • Hot methods (що викликаються часто) “тонуть” серед cold methods — JIT compiler витрачає budget compilation на аналіз всього класу.

Після розділення:

  • Hot методи в маленьких класах inline’яться агресивно.
  • JIT compilation budget витрачається ефективно.
  • CPU instruction cache утилізація вища — код маленьких класів вміщується в L1 cache (32 КБ).

Архітектурні trade-offs

Підхід Pros Cons
Повна декомпозиція Кожен клас SRP, легко тестувати, легко заміняти «Class Explosion» — 50 маленьких класів замість одного, навігація ускладнюється
Часткова декомпозиція Баланс між простотою і модульністю Межі можуть бути розмиті
Facade + внутрішні класи Зворотна сумісність, поступова міграція Facade залишається точкою coupling
Не чіпати Нульовий ризик поломки Технічний борг росте, onboarding нових розробників — тижні

Edge Cases

1. Shared Mutable State: God Object з 50 полями, які використовуються всіма методами впереміш. При розділенні кожне нове поле повинно «належати» одному класу.

Рішення — Context Object:

public class OrderProcessingContext {
    public Order order;
    public User customer;
    public PaymentMethod payment;
    public ShippingDetails shipping;
}
// Передається як параметр замість спільного стану

2. Транзакційна цілісність після розділення:

// Було: один synchronized-метод в God Object
public synchronized void processOrder() {
    saveToDb(order);       // Репозиторій 1
    sendEmail(customer);   // Сервіс 2
    updateInventory(item); // Сервіс 3
}

Після розділення processOrder повинен координувати 3 сервіси. synchronized(this) більше не працює.

Рішення:

  • В моноліті: @Transactional на фасадному методі.
  • В розподіленій системі: Saga Pattern з compensating transactions.

3. Циклічні залежності після розділення:

class UserService { EmailService email; }
class EmailService { UserService user; } // ЦИКЛ!

Виникає, коли виділені класи посилаються один на одного.

Рішення: введіть третій координатор (UserNotificationOrchestrator) або використовуйте events (ApplicationEventPublisher).

4. God Object як «Util» клас:

public class Utils {
    public static void sort(...) { ... }
    public static void validate(...) { ... }
    public static void format(...) { ... }
    public static void encrypt(...) { ... }
}

Статичний God Object. Неможливо мокати, неможливо наслідувати.

Рішення: розбийте на SortUtils, Validator, Formatter, Encryptor — або краще, використовуйте готові бібліотеки (Guava, Apache Commons).

Performance Implications

Метрика God Object (200 методів) Після розділення
Class loading 50–100 КБ metaspace 10 × 5 КБ = 50 КБ metaspace
JIT warmup 30–60 сек (аналіз 200 методів) 10–20 сек (кожен клас компілюється окремо)
L1 I-cache miss 15–25% (код не вміщується) 5–8% (маленькі класи cache-friendly)
Benchmark (ops/ms) ~8,000 (hot path) ~12,000 (+50% після розділення hot/cold logic)

Hot vs Cold logic separation:

  • Hot: валідація, розрахунки, серіалізація — викликаються тисячі разів/сек.
  • Cold: логування, конфігурація, метрики — викликаються одиниці разів/сек.
  • Коли вони в одному класі, JIT компілює обидва типи в один code blob — CPU cache забруднюється.
  • Розділення дає hot-класам пріоритет в instruction cache.

Memory Implications і GC Impact

  • God Object: один великий об’єкт = один large allocation. Якщо об’єкт > 512 КБ — йде відразу в Old Generation (TLAB overflow), провокуючи Full GC.
  • Розділені об’єкти: множество маленьких об’єктів = алокація в Eden, швидкий збір Young GC.
  • GC pause: God Object в 1 МБ = 10–50 мс на Full GC. 100 маленьких об’єктів = 1–3 мс на Young GC.

Thread Safety

  • God Object + synchronized = bottleneck. Один монитор блокує ВСІ методи, навіть незв’язані. Потік A викликає logActivity(), потік B чекає, хоча викликає createUser() — вони не конфліктують по даних, але блокуються на одному мониторі.
  • Після розділення: кожен клас має свій монитор. AuditLogger.synchronized і UserService.synchronized не блокують один одного.
  • Lock strip: замість одного synchronized використовуйте ReentrantLock на кожну логічну групу даних.

Production War Story

Проблема: Платіжний сервіс PaymentProcessor мав 4500 рядків, 120 методів, 35 полів. Обробляв 500 транзакцій/сек. При піковому навантаженні (Чорна п’ятниця, 5000 транзакцій/сек) сервіс деградував: latency виріс з 50 мс до 2 сек.

Діагностика:

  • Async Profiler показав, що 60% CPU витрачається на synchronized contention — 15 потоків чекали один монитор.
  • LCOM = 4.2 (критичне значення > 1.0) — клас мав 4 незв’язаних кластери методів.
  • Class file size: 180 КБ (норма < 20 КБ).

Рішення (покроково, без зупинки сервісу):

  1. Тиждень 1: Написали characterization tests (тести, які фіксують поточну поведінку системи — ви не знаєте, правильна вона чи ні, але знаєте, що вона працює; після рефакторингу ці тести гарантують, що поведінка не змінилася) на кожен публічний метод (87 тестів).
  2. Тиждень 2: Виділили PaymentValidator (15 методів) — LCOM впав до 3.1.
  3. Тиждень 3: Виділили PaymentLogger (8 методів), FraudDetector (20 методів) — LCOM 1.8.
  4. Тиждень 4: PaymentProcessor став фасадом, що делегує 4 новим класам. Клієнти не змінилися.
  5. Тиждень 5: Замінили synchronized на ReentrantReadWriteLock — read operations (check status) не блокують один одного.
  6. Тиждень 6: Перевели клієнтів на прямі виклики до нових сервісів, видалили фасад.

Результат:

  • Latency при 5000 TPS: 50 мс (було 2000 мс — 40x покращення).
  • GC pauses: 3 мс (було 50 мс).
  • LCOM: 0.4 (норма < 1.0).

Monitoring і Diagnostics

ArchUnit — правила для CI:

@ArchTest
static final ArchRule no_god_objects =
    noClasses().that().haveNameNotMatching(".*(Config|Application).*")
        .should().haveMethodsMoreThan(30);

SonarQube — метрики:

  • NCSS (Non-Commenting Source Statements) — ціль: < 500 на клас.
  • Cyclomatic Complexity — ціль: < 15 на метод.
  • LCOM — ціль: < 1.0.
  • Правило S1448: «Classes should not have too many methods» (default threshold: 35).

Structure101 — візуалізація «зірок» зв’язності. God Object відображається як вузол з максимальним ступенем (degree centrality).

jQAssistant — Neo4j-based аналіз:

MATCH (c:Class)
WHERE size((c)-[:DECLARES]->()) > 50
RETURN c.name, size((c)-[:DECLARES]->()) as methodCount
ORDER BY methodCount DESC

IntelliJ IDEA — «Metrics Reloaded» плагін: показує LCOM, зв’язність (coupling), глибину наслідування прямо в редакторі.

Best Practices для Highload

  1. Розділяйте hot/cold logic — hot-класи повинні бути маленькими і JIT-friendly.
  2. Lock granularity — один synchronized на God Object = deadlock ризику немає, але throughput вбитий. Розділення = fine-grained locking.
  3. Context Object замість shared state — передавайте immutable context, уникайте мутацій.
  4. Saga для розподілених транзакцій — після розділення God Object на мікросервіси, @Transactional більше не працює.
  5. Progressive refactoring — спочатку делегування (фасад), потім міграція клієнтів, потім видалення фасаду. Ніякого Big Bang.
  6. Characterization tests FIRST — без них рефакторинг God Object — це стрільба всліпу.
  7. Bounded Contexts (DDD) (обмежені контексти з Domain-Driven Design — розділення системи за бізнес-межами, де кожна частина має свою модель і термінологію) — ріжте по бізнес-межах, не по технічних. UserService, PaymentService, NotificationService — це bounded contexts.

Резюме для Senior

  • Ріжте God Object по межах Bounded Contexts (з DDD), не по технічних шарах.
  • Використовуйте делегування як проміжний етап — безпечна міграція без downtime.
  • Не бійтеся «Class Explosion» — 20 маленьких SRP-класів краще, ніж один некерований монстр.
  • God Object не тільки погано читається — він погано масштабується в багатопоточному середовищі (single monitor bottleneck).
  • Characterization tests — обов’язковий перший крок. Без них ви не рефакторите, ви вгадуєте.

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

Обов’язково знати:

  • God Object — клас з сотнями методів/полів, робить “все”, LCOM > 1.0
  • Алгоритм: Feature Analysis → Extract Class (делегування) → ISP інтерфейси → міграція клієнтів
  • Characterization Tests — обов’язковий перший крок для legacy без тестів
  • Strangler Fig: поступова заміна через фасад, ніякого Big Bang
  • Hot vs Cold logic separation: hot-класи маленькі для JIT-friendly, cold — можна більше
  • Saga Pattern для розподілених транзакцій після розділення God Object на мікросервіси
  • Bounded Contexts (DDD) — ріжте по бізнес-межах, не по технічних шарах

Часті уточнюючі запитання:

  • Як розділити shared mutable state? — Context Object: передати стан як параметр замість спільних полів
  • Що робити з циклічними залежностями після розділення? — Третій координатор або Event-driven (ApplicationEventPublisher)
  • God Object і Thread Safety? — Один synchronized блокує ВСІ методи; розділення → fine-grained locking
  • Метрики God Object? — >30 методів, >15 полів, LCOM > 1.0, Cognitive Complexity > 15, >7 залежностей

Червоні прапори (НЕ говорити):

  • “God Object можна рефакторити без тестів” (стрільба всліпу, гарантовані регресії)
  • “Потрібно розділити все відразу за один спринт” (progressive refactoring: фасад → міграція → видалення)
  • “20 маленьких класів гірше одного God Object” (20 SRP-класів краще одного некерованого монстра)

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

  • [[1. Що таке принцип Single Responsibility і як його застосовувати]]
  • [[14. Що станеться, якщо клас має кілька причин для зміни]]
  • [[22. Які антипатерни суперечать принципам SOLID]]
  • [[13. Як принцип Single Responsibility пов’язаний з cohesion]]