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

Що станеться, якщо клас має кілька причин для зміни

Коли клас має кілька причин для зміни, він порушує перший принцип SOLID — Single Responsibility Principle (SRP).


🟢 Junior Level

Коли клас має кілька причин для зміни, він порушує перший принцип SOLID — Single Responsibility Principle (SRP).

У формулюванні Роберта Мартіна (Дядька Боба):

«Причина для зміни — це людина, роль або бізнес-актор, що вимагає доопрацювання системи.»

Якщо один клас одночасно обслуговує інтереси бухгалтера, співробітника складу та системного адміністратора, він перетворюється на крихкий клубок проблем.

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

Швейцарський ніж із 50 насадками:

  • Якщо у вас затупилося лезо і ви віддаєте ніж на заточування, ви одночасно втрачаєте штопор, ножиці та викрутку.
  • Якщо майстер під час заміни пружини ножиць випадково пошкодить замок штопора, ви дізнаєтеся про це лише тоді, коли спробуєте відкрити пляшку на святі.

Наочний приклад порушення SRP в Java

// Клас порушує SRP: 4 різні причини для зміни від 4 різних стейкхолдерів!
public class Employee {
    private String id;
    private BigDecimal salary;

    // Причина 1 (Фінансовий відділ / CFO): алгоритм розрахунку окладу та премій
    public BigDecimal calculatePay() {
        return salary.multiply(BigDecimal.valueOf(1.2));
    }

    // Причина 2 (Відділ кадрів / HR): облік відпрацьованих годин та лікарняних
    public String reportHours() {
        return "Employee " + id + " worked 40 hours";
    }

    // Причина 3 (Відділ безпеки / DBA): схема збереження в базу даних
    public void saveToDatabase() {
        System.out.println("INSERT INTO employees VALUES (" + id + ", " + salary + ")");
    }

    // Причина 4 (Фронтенд / Mobile API): серіалізація в JSON для клієнта
    public String toJson() {
        return "{\"id\":\"" + id + "\"}";
    }
}

Що станеться на практиці:

  1. Каскадні поломки (Side Effects): Бухгалтер попросив округлювати премії. Розробник змінює спільну службову змінну, і раптово ламається генерація звіту для HR.
  2. Merge-конфлікти в Git: Дві незалежні команди одночасно змінюють один і той самий файл Employee.java. Злиття гілок перетворюється на жахіття.
  3. Неможливість протестувати ізольовано: Щоб протестувати математику розрахунку зарплати, доводиться налаштовувати моки для бази даних та JSON-серіалізатора.

🟡 Middle Level

5 системних наслідків порушення SRP

                 ┌────────────────────────────────────────────────────────┐
                 │                 Клас із 3 акторами                     │
                 │              (Бухгалтер, HR, Безпека)                  │
                 └───────────────────────────┬────────────────────────────┘
                                             │
      ┌──────────────────┬───────────────────┼──────────────────┬──────────────────┐
      ▼                  ▼                   ▼                  ▼                  ▼
 Каскадні баги     Merge-конфлікти      Когнітивне        Роздуті моки       Хибні блокування
  (Side Effects)    у Git репозиторії    перевантаження    в тестах          (Lock Contention)
  1. Крихкість (Fragility) та ефект метелика: Зміна логіки форматування для вебклієнта ненавмисно мутує внутрішній стан сутності, призводячи до пошкодження даних під час подальшого запису в БД.
  2. Війна за вихідний код (Merge Hell): Згідно із законом Конвея (Conway’s Law), структура коду відображає комунікації в організації. Коли програміст із команди білінгу та програміст із команди профілю користувача одночасно вносять правки в один клас, CI/CD блокується постійними конфліктами злиття.
  3. Зростання когнітивного навантаження (Cognitive Load): Розробник не може зрозуміти роботу одного методу, не вивчивши решту 2000 рядків чужої функціональності. З’являється страх внесення змін до коду (Fear of touching legacy code).
  4. Нескінченний Code Churn: Метрика Code Churn показує частоту зміни файлу. Якщо файл OrderService.java модифікується у 80% усіх Pull Request компанії — це вірна ознака порушеного SRP.
  5. Розмиття меж транзакцій (Transaction Boundaries): Метод, який одночасно рахує логіку та надсилає сповіщення, часто огортається в @Transactional. Якщо відправка Email або виклик зовнішнього REST API зависне на 30 секунд, транзакція БД утримує з’єднання з пулу HikariCP, спричиняючи вичерпання пулу та падіння всього сервера.

🔴 Senior Level

Апаратні та системні проблеми монолітного класу в JVM

На рівні віртуальної машини та апаратного забезпечення монолітний клас (God Object) створює приховані деградації:

1. Апаратний False Sharing на рівні L1/L2 кешу процесора

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

public class HeavyMonolithicOrder {
    // Використовується воркером процесингу замовлень:
    public volatile long processedCounter; 

    // Використовується фоновим потоком метрик моніторингу:
    public volatile long heartbeatTimestamp; 
}

Обидва поля потрапляють в одну 64-байтну кеш-лінію CPU (Cache Line). Коли ядро процесора №1 інкрементує processedCounter, протокол когерентності кешів (MESI) примусово інвалідує всю кеш-лінію в ядрі №2, яке читає heartbeatTimestamp. Швидкість міжпотокової роботи падає в десятки разів через постійне перезавантаження кешу з оперативної пам’яті.

2. Блокування монітора (Lock Contention)

Якщо в класі є методи synchronized для різних обов’язків, потоки, що виконують розрахунок звітів, стають у чергу за потоками, які зберігають замовлення в базу, блокуючи єдиний монітор this.

3. Metaspace та фрагментація збирача сміття

Гігантські класи (3000+ рядків байткоду) не можуть бути оптимізовані компілятором HotSpot C2 (поріг інлайнінгу MaxInlineLevel та InlineSmallCode). Методи залишаються неінлайненими, викликаючи постійний оверхед віртуальних викликів та перешкоджаючи скалярній заміні об’єктів на стеку (Escape Analysis).


Архітектурне рішення: Патерн Orchestrator + Facade

Щоб ліквідувати множинні причини для зміни, бізнес-логіка декомпозується на незалежні функціональні сервіси, а координація покладається на чистий легковажний Оркестратор:

// 1. Вузькі сервіси: рівно 1 причина для зміни
@Service
public class PayrollCalculator {
    public BigDecimal calculateSalary(Employee employee) { /* тільки математика */ }
}

@Service
public class EmployeeRepository {
    public void persist(Employee employee) { /* тільки SQL/JPA */ }
}

@Service
public class EmployeeNotifier {
    public void notifyHired(Employee employee) { /* тільки сповіщення */ }
}

// 2. Оркестратор: керує процесом, але НЕ містить бізнес-логіки розрахунку чи SQL
@Service
public class OnboardingOrchestrator {
    private final PayrollCalculator calculator;
    private final EmployeeRepository repository;
    private final EmployeeNotifier notifier;

    public OnboardingOrchestrator(PayrollCalculator calc, EmployeeRepository repo, EmployeeNotifier notif) {
        this.calculator = calc;
        this.repository = repo;
        this.notifier = notif;
    }

    public void onboardNewEmployee(Employee employee) {
        calculator.calculateSalary(employee);
        repository.persist(employee);
        notifier.notifyHired(employee);
    }
}

4 Tricky Questions

1. Як закон Конвея (Conway’s Law) пояснює проблеми класу з кількома причинами для зміни?

Відповідь: Закон Конвея стверджує: «Організації проєктують системи, які копіюють структуру їхніх власних комунікацій». Якщо клас має кілька причин для зміни, це означає, що код намагається об’єднати вимоги кількох незалежних команд або стейкхолдерів (наприклад, бухгалтерії, відділу продажів та інженерів інфраструктури). Через відсутність прямої координації між цими групами в реальному житті клас у кодовій базі неминуче стає ареною конфліктів вимог, суперечливих правок та регресійних багів. Рішення за SRP — розділити клас за організаційними межами команд (Bounded Contexts).


2. Чи є клас UserDTO, що містить 40 полів для всіх сторінок особистого кабінету, порушенням SRP?

Відповідь: Так. Це класичний антипатерн «Fat DTO / Generic DTO». Клас змінюється щоразу, коли змінюються вимоги до сторінки профілю, сторінки безпеки, платіжних даних або сторінки доставки. Правильний дизайн: поділ на спеціалізовані легковажні DTO під кожен сценарій використання (Role-based / View-specific DTOs): UserProfileViewDto, UserSecuritySettingsDto, UserBillingAddressDto. Це знижує трафік мережі, виключає випадковий витік конфіденційних даних та захищає від конфліктів серіалізації.


3. Чому клас із кількома відповідальностями майже завжди призводить до витоку з’єднань із пулу БД?

Відповідь: Коли метод класу поєднує роботу з БД (відповідальність репозиторію) та звернення до зовнішнього стороннього API (відповідальність мережевого клієнта), розробники часто вішають анотацію @Transactional на весь метод. Транзакція відкривається на початку методу і захоплює фізичний сокет із пулу конектів (HikariCP). При зверненні до зовнішнього API через мережу виникають затримки (Network I/O, таймаути по 10–30 секунд). З’єднання з БД простоює заблокованим. Під навантаженням пул з’єднань вичерпується за секунди, викликаючи відмову в обслуговуванні всієї бази даних (ConnectionTimeoutException).


4. Як за допомогою системи контролю версій Git математично довести менеджменту необхідність рефакторингу монолітного класу?

Відповідь: За допомогою аналізу метрик Code Churn та Hotspots:

  1. Виконується команда git log --format=format: --name-only --since="1 year ago" | sort | uniq -c | sort -nr | head -20.
  2. Перевіряється, чи входить проблемний клас у топ-3 найбільш часто змінюваних файлів за рік.
  3. Зіставляються логи закриття баг-тікетів: скільки регресійних багів у Jira було пов’язано з комітами в цей файл.
  4. Розраховується сумарний час, витрачений розробниками на розв’язання merge-конфліктів. Наочний графік частоти змін та кількості пов’язаних дефектів об’єктивно обґрунтовує виділення спринтів на розпил моноліту за SRP.

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

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

  • Суть проблеми: Клас залежить від кількох бізнес-акторів (стейкхолдерів), тому змінюється занадто часто та непередбачувано.
  • Головні симптоми: Каскадні регресійні баги (ефект метелика), постійні merge-конфлікти в Git, роздуті нечитабельні юніт-тести з десятками моків.
  • Апаратні ризики: Зависання пулів з’єднань БД всередині @Transactional, міжпотоковий False Sharing на 64-байтних кеш-лініях CPU.
  • Патерн рішення: Декомпозиція логіки на вузькі спеціалізовані сервіси + легковажний патерн Orchestrator/Facade для координації.

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

  • ❌ Вважати причиною для зміни просто технічну дію («додали лог — значить змінили причину»). Причина для зміни — це завжди зовнішній стейкхолдер/актор бізнес-логіки.
  • ❌ Намагатися вирішити проблему монолітного класу виносом методів у статичні утиліти EmployeeUtils (це лише маскує дефект).

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