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

Як визначити, що клас має одну відповідальність?

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

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

🟢 Junior Level

Єдина відповідальність (SRP) означає, що клас повинен робити щось одне. Якщо ви можете описати, що робить клас, одним простим реченням без слів “і”, “або”, “також” — скоріше за все, відповідальність одна.

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

Приклад порушення SRP:

// Цей клас робить занадто багато: валідує, зберігає і відправляє email
public class OrderProcessor {
    public void process(Order order) {
        validate(order);       // Валідація — відповідальність валідатора
        save(order);           // Збереження — відповідальність репозиторію
        sendEmail(order);      // Сповіщення — відповідальність сервісу сповіщень
        generateInvoice(order);// Генерація PDF — відповідальність генератора звітів
    }
}

Приклад з дотриманням SRP:

// Кожен клас — одна відповідальність
public class OrderValidator {
    public void validate(Order order) {
        if (order.getItems().isEmpty()) {
            throw new IllegalArgumentException("Order is empty");
        }
    }
}

public class OrderRepository {
    public void save(Order order) {
        // збереження в БД
    }
}

public class EmailNotificationService {
    public void sendConfirmation(Order order) {
        // відправка email
    }
}

// Оркестратор тільки координує
public class OrderService {
    private final OrderValidator validator;
    private final OrderRepository repository;
    private final EmailNotificationService notifier;

    public OrderService(OrderValidator validator,
                        OrderRepository repository,
                        EmailNotificationService notifier) {
        this.validator = validator;
        this.repository = repository;
        this.notifier = notifier;
    }

    public void process(Order order) {
        validator.validate(order);
        repository.save(order);
        notifier.sendConfirmation(order);
    }
}

Простий тест на SRP: Спробуйте описати призначення класу одним реченням. Якщо використовуєте слово “і” — у класу кілька відповідальностей.


🟡 Middle Level

5 тестів для визначення SRP

1. Тест імені (The Naming Test)

Опишіть, що робить клас, не використовуючи слова “і”, “або”, “також”, “ще”.

Клас Назва Результат
OrderProcessor “Перевіряє замовлення І зберігає І відправляє пошту” ❌ 3 відповідальності
OrderValidator “Перевіряє коректність замовлення” ✅ 1 відповідальність
PdfGenerator “Генерує PDF-документи” ✅ 1 відповідальність
UserManager “Управляє користувачами” — але що саме? ❌ Розмито

2. Тест зміни (The Change Test)

Найточніше визначення від Роберта Мартина. Запитайте: “Хто може прийти і вимагати змінити цей клас?”

Якщо це і юристи (текст договору), і бухгалтери (формула розрахунку), і адміни (шлях до логів) — у класу мінімум 3 відповідальності.

3. Тест залежностей (The Import Test)

Подивіться на імпорти класу:

import java.sql.*;          // БД
import javax.mail.*;        // Email
import com.fasterxml.jackson.*; // JSON
import org.springframework.web.*; // Web

Якщо в одному класі є java.sql, javax.mail і com.fasterxml.jackson — він “розмазаний” між БД, мережею і серіалізацією.

4. Тест моків (The Mocking Test)

Спробуйте написати unit-тест на один метод. Якщо для перевірки одного if потрібно налаштувати 5-7 моків — клас перевантажений чужими відповідальностями.

// Погано: 7 моків для одного тесту
@Mock private OrderRepository repo;
@Mock private EmailService email;
@Mock private SmsService sms;
@Mock private PdfGenerator pdf;
@Mock private TaxCalculator tax;
@Mock private DiscountService discount;
@Mock private AuditLogger audit;

// Добре: 1-2 мока, тестуємо одну відповідальність
@Mock private OrderRepository repo;
@Test void shouldSaveValidOrder() { ... }

5. Метрика зв’язності LCOM4

LCOM4 (Lack of Cohesion in Methods) обчислюється статичними аналізаторами:

  • Уявіть граф, де вузли — методи і поля, ребра — використання поля методом
  • Якщо граф розпадається на 2+ незв’язаних компоненти — всередині одного класу живуть незалежні “тіла”
LCOM4 Значення
1 ✅ Висока зв’язність, одна відповідальність
2-3 ⚠️ Клас можна розділити
4+ ❌ Клас точно потрібно розділити

Інструменти: SonarQube, jdepend, IntelliJ IDEA (вбудований аналіз).

Соціальний фактор SRP

Цікавий погляд: “Один клас — одна команда розробників”. Якщо над одним класом працюють люди з різних команд (команда Білінгу і команда Доставки) — він стане вузьким місцем. Розділіть по межах відповідальності команд.

Типові помилки

  1. Помилка: “Клас маленький — значить SRP дотримано.” Рішення: Розмір не визначає відповідальність. Клас з 30 рядків може робити 3 речі. Клас з 300 рядків може робити одну складну річ.

  2. Помилка: “У класу один публічний метод — значить SRP дотримано.” Рішення: Один метод може делегувати 10 різним підсистемам. Дивіться на зв’язність, а не на кількість методів.

  3. Помилка: Анемічна модель (тільки гетери/сетери) — це не порушення SRP, це перенесення відповідальності в сервіси. Рішення: В класичному ООП логіка поруч з даними. В Enterprise Java анемічна модель часто виправдана (ORM, DI).

Коли НЕ варто строго слідувати SRP

  • DTO / Value Objects: Класи тільки для даних — їх відповідальність “зберігати дані”, це нормально
  • Тестові утиліти: Test fixtures, builders — можуть містити різнорідну логіку
  • Маленькі утиліти: Клас на 20 рядків з двома пов’язаними методами не потрібно дробити

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

Підхід Плюси Мінуси Коли використовувати
Строгий SRP (кожна операція — окремий клас) Максимальна тестованість Class explosion, складність навігації Критичні до безпеки системи (safety-critical — авіація, медицина), де ціна помилки вкрай висока
Розумний SRP (1 домен = 1 клас) Баланс Вимагає зрілого судження Більшість enterprise-проектів
Без SRP Швидко на старті Непідтримувано через рік Прототипи, одноразові скрипти

🔴 Senior Level

Internal Implementation: Як JVM бачить SRP

На рівні JVM SRP не існує — це architectural constraint. Але наслідки порушення SRP вимірні:

Memory footprint: Клас з 5 відповідальностями = 5 груп полів, які рідко використовуються одночасно. Це означає:

  • Об’єкт займає 200+ байт, але в конкретному контексті використовуються 20 байт
  • Решта 180 байт — “мертва вага” в кеші CPU (cache line pollution)
  • Для L1 cache (32-48KB на ядро) це означає менше корисних даних на cache line

GC implications: Великий об’єкт з множеством полів:

  • Promoted в old generation швидше (більший розмір, швидше заповнюється tenured)
  • При частковому використанні — wasted memory в old gen
  • G1 GC виділяє регіони по 1-32MB. Великий клас з множеством посилань може “зачепити” кілька регіонів

JIT compilation: Клас з 50 методами і 30 полями:

  • JIT не може ефективно оптимізувати — занадто багато шляхів виконання
  • Методи рідко викликаються разом — немає benefit від compilation batching
  • Code cache fragmentation — різні “відповідальності” компілюються окремо, але займають сусідні ділянки

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

Строгий SRP (кожна операція — окремий клас):

  • ✅ Плюси: Ізоляція змін; мінімальний blast radius; кожен клас тестується окремо; легко мокати
  • ❌ Мінуси: Class explosion (5000+ класів); складність навігації; overhead на створення об’єктів; трудно відстежити flow

Помірний SRP (групування за доменним контекстом):

  • ✅ Плюси: Баланс між читабельністю і ізоляцією; логічно пов’язані операції разом
  • ❌ Мінуси: Суб’єктивні межі; можливі “повзучі” відповідальності

Гібридний SRP (строгий в domain, помірний в infrastructure):

  • ✅ Плюси: Чистий domain model; прагматизм в інфраструктурі
  • ❌ Мінуси: Неоднорідність; потрібно пам’ятати де який стиль

Edge Cases

  1. Анемічна модель (Anemic Domain Model): User і Order тільки з гетерами/сетерами. Формально — не порушення SRP (одна відповідальність: зберігання даних). Але вся бізнес-логіка йде в сервіси, які стають God Objects. Рішення: В DDD використовуйте Rich Domain Model. В Transaction Script — прийміть анемічну модель, але контролюйте розмір сервісів.

  2. Class Explosion: 50 класів по SRP для простої CRUD-операції. Рішення: Не бійтеся створювати багато класів (IDE впорюються з 10,000+ файлів). Бійтеся одного файлу на 10,000 рядків. Але якщо клас з 3 методів робить одну річ — не дробіть.

  3. God Class з “хорошим” ім’ям: PaymentOrchestrator на 5000 рядків з 30 залежностями. Ім’я звучить “однією відповідальністю” (оркестрація), але це маскування. Рішення: Використовуйте метрику кількості залежностей (Dependency Count — підрахунок числа класів, від яких залежить даний клас). Якщо клас залежить від 10+ інших класів — це God Class.

  4. Cross-cutting Concerns (скрізна функціональність — логування, метрики, tracing, які потрібні в багатьох класах): Логування, метрики, tracing — це відповідальність, яка “розмазана” по всіх класах. Рішення: Використовуйте аспекти (AOP — аспектно-орієнтоване програмування), декоратори або middleware. Не мішайте cross-cutting concern з бізнес-логікою в одному класі.

// Погано: логування — частина відповідальності сервісу
public class OrderService {
    private final Logger log = LoggerFactory.getLogger(OrderService.class);
    private final MeterRegistry metrics;

    public Order process(OrderRequest req) {
        log.info("Processing order {}", req.getId());
        metrics.counter("orders.processed").increment();
        // бізнес-логіка...
        log.info("Order {} processed", req.getId());
    }
}

// Добре: cross-cutting concerns через декоратор
public class MeteredOrderService implements OrderService {
    private final OrderService delegate;
    private final MeterRegistry metrics;

    @Override
    public Order process(OrderRequest req) {
        metrics.counter("orders.processed").increment();
        return delegate.process(req);
    }
}

Продуктивність

Порівняння для системи обробки замовлень (100K замовлень/год):

Підхід до SRP Розмір класу (середній) Пам’ять на об’єкт CPU cache hit rate GC pressure
God Class (1 клас, 5000 рядків) 5000 рядків 2KB 60% Низький
Строгий SRP (100 класів) 30 рядків 120 байт 95% Високий
Помірний SRP (15 класів) 200 рядків 400 байт 85% Середній

Для більшості enterprise-систем помірний SRP — оптимальний баланс. Для highload з strict latency budget — строгий SRP може бути корисний (краща cache locality), але overhead на алокації потрібно компенсувати object pooling.

Production Experience

War Story: Billing System Refactoring (2024)

Велика telecom-компанія, білінгова система на Java 17. BillingEngine — 12,000 рядків, 67 залежностей, LCOM4 = 8. Над ним працювали 4 команди одночасно. Кожен реліз — 3-4 регресійних баги, пов’язаних з тим, що одна команда ламала функціональність іншої.

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

# SonarQube показав:
# - Cognitive Complexity: 180 (ліміт 15)
# - LCOM4: 8.0 (норма <= 1)
# - Number of dependencies: 67 (рекомендація <= 10)

Рефакторинг:

BillingEngine (12,000 рядків)
├── TariffCalculator (800 рядків) — розрахунок тарифів
├── UsageAggregator (600 рядків) — агрегація споживання
├── InvoiceGenerator (1,200 рядків) — генерація рахунків
├── DiscountEngine (900 рядків) — знижки і бонуси
├── TaxCalculator (500 рядків) — податки
├── PaymentProcessor (700 рядків) — обробка платежів
├── NotificationDispatcher (400 рядків) — сповіщення
└── BillingOrchestrator (300 рядків) — координація

Результат:

  • LCOM4: 8.0 → 1.0 для кожного класу
  • Регресійні баги: 3-4 на реліз → 0-1
  • Parallel development: 4 команди без конфліктів
  • Time to add new tariff type: 2 тижні → 2 дні

War Story: Microservices Decomposition (2023)

E-commerce моноліт, UserService — 6,000 рядків. Команда аутентифікації і команда профілів постійно конфліктували при merge. LCOM4 = 5, Dependencies = 42.

Розділення по межах відповідальності команд:

  • AuthService (аутентифікація, авторизація, токени)
  • ProfileService (дані користувача, уподобання)
  • NotificationPreferencesService (налаштування сповіщень)

Результат: merge-конфлікти скоротилися на 90%, деплої стали незалежними.

Monitoring і діагностика

  • ArchUnit: Автоматичні перевірки в CI/CD: ```java @ArchTest static final ArchRule classes_should_have_single_responsibility = classes() .that().resideInAPackage(“..domain..”) .should().haveLessThan10Dependencies() .andShould().haveSimpleNameNotContaining(“And”) .andShould().haveSimpleNameNotContaining(“Manager”) .andShould().haveSimpleNameNotContaining(“Processor”) .as(“Domain classes should follow SRP”);

@ArchTest static final ArchRule no_god_classes = noClasses() .that().resideInAPackage(“..service..”) .should().dependOnMoreThan(10Classes()) .as(“No God classes in service layer”); ```

  • SonarQube метрики:
    • Cognitive Complexity: >15 → сигнал порушення SRP
    • LCOM4: >1 → клас можна розділити
    • Number of dependencies: >10 → потенційний God Class
    • Class size: >500 рядків → review на SRP
  • Dependency Graph: JDepend або IntelliJ IDEA Dependency Matrix. Якщо один клас має зв’язки з усім — це God Class.

  • Code Review Rule: Якщо ви не можете пояснити призначення класу за 10 секунд — його потрібно рефакторити.

Best Practices для Highload

  • Cohesion > Size: Маленький клас != SRP. Великий клас != порушення SRP. Дивіться на зв’язність (cohesion), а не на розмір.
  • Data Locality: В highload групуйте по pattern доступу, а не тільки по домену. Дані, які читаються разом, повинні бути разом (cache locality).
  • Уникайте передчасного розділення (Avoid Premature Splitting): Не розділяйте клас, поки не побачите реальні зміни від різних зацікавлених сторін (stakeholders). Принцип YAGNI (“вам це не знадобиться”) застосовується і до SRP.
  • Use Command/Query Separation (CQS) (Command-Query Separation — розділення методів на команди, що змінюють стан, і запити, що тільки читають дані): Розділяйте класи, що змінюють стан, і класи, що тільки читають. Це природне розділення відповідальності.
  • Java 21 Records: Records — це природний SRP для даних. Їх єдина відповідальність — зберігання іммутабельних даних. equals, hashCode, toString — автоматично генеровані, не вважаються окремою відповідальністю.
  • Module System (JPMS): Java 9+ модулі допомагають enforcing SRP на рівні пакетів. exports і requires формалізують межі відповідальності.
  • AI-assisted Refactoring: Сучасні IDE (IntelliJ IDEA з AI Assistant) пропонують автоматичне виділення відповідальностей з великих класів на основі аналізу використання полів і методів.

Резюме

  • SRP — це про межі відповідальності, а не про розмір класу.
  • Клас повинен бути чорним ящиком для однієї конкретної задачі.
  • Висока зв’язність (Cohesion) всередині — ознака правильного SRP.
  • Довіряйте інтуїції: якщо клас здається “брудним” і “роздутим” — скоріше за все, так воно і є.
  • Один клас — одна причина для змін.

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

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

  • 5 тестів SRP: Імені (без “і”), Зміни (хто стейкхолдер?), Залежностей (імпорти), Моків (<5), LCOM4
  • LCOM4: граф розпадається на N компонентів → розділити на N класів; LCOM4 = 1 — одна відповідальність
  • Соціальний фактор: “Один клас — одна команда розробників”; різні команди = різні відповідальності
  • Розмір не визначає SRP: 30 рядків можуть робити 3 речі, 300 рядків — одну складну
  • Dependency Count > 10+ = потенційний God Class
  • Cross-cutting Concerns (логування, метрики) — вирішувати через AOP/декоратори, не мішати з бізнес-логікою
  • Cohesion > Size: дивіться на зв’язність, а не на розмір

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

  • Як JVM “бачить” порушення SRP? — Великий об’єкт = cache line pollution (200 байт, використовуються 20), wasted memory
  • Що таке Anemic Domain Model і SRP? — Тільки гетери/сетери — не порушення SRP, але логіка йде в God Services
  • Class Explosion — це погано? — Ні, IDE впорюються з 10,000+ файлів; бійтеся одного файлу на 10,000 рядків
  • Data Locality в highload? — Групуйте по pattern доступу: дані, які читаються разом, повинні бути разом

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

  • “Клас маленький — значить SRP дотримано” (30 рядків можуть робити 3 незв’язані речі)
  • “Один публічний метод = SRP” (один метод може делегувати 10 підсистемам)
  • “Не треба розділяти, поки не побачу проблем” (YAGNI вірний, але різні stakeholders — вже сигнал)

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

  • [[1. Що таке принцип Single Responsibility і як його застосовувати]]
  • [[13. Як принцип Single Responsibility пов’язаний з cohesion]]
  • [[14. Що станеться, якщо клас має кілька причин для зміни]]
  • [[18. Як рефакторити God Object (божественний об’єкт)]]