Як визначити, що клас має одну відповідальність?
Уявіть офіс: бухгалтер рахує зарплату, юрист перевіряє договори, системний адміністратор налаштовує сервери. Якщо одна людина намагається робити все одразу — якість падає.
🟢 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
Цікавий погляд: “Один клас — одна команда розробників”. Якщо над одним класом працюють люди з різних команд (команда Білінгу і команда Доставки) — він стане вузьким місцем. Розділіть по межах відповідальності команд.
Типові помилки
-
Помилка: “Клас маленький — значить SRP дотримано.” Рішення: Розмір не визначає відповідальність. Клас з 30 рядків може робити 3 речі. Клас з 300 рядків може робити одну складну річ.
-
Помилка: “У класу один публічний метод — значить SRP дотримано.” Рішення: Один метод може делегувати 10 різним підсистемам. Дивіться на зв’язність, а не на кількість методів.
-
Помилка: Анемічна модель (тільки гетери/сетери) — це не порушення 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
-
Анемічна модель (Anemic Domain Model):
UserіOrderтільки з гетерами/сетерами. Формально — не порушення SRP (одна відповідальність: зберігання даних). Але вся бізнес-логіка йде в сервіси, які стають God Objects. Рішення: В DDD використовуйте Rich Domain Model. В Transaction Script — прийміть анемічну модель, але контролюйте розмір сервісів. -
Class Explosion: 50 класів по SRP для простої CRUD-операції. Рішення: Не бійтеся створювати багато класів (IDE впорюються з 10,000+ файлів). Бійтеся одного файлу на 10,000 рядків. Але якщо клас з 3 методів робить одну річ — не дробіть.
-
God Class з “хорошим” ім’ям:
PaymentOrchestratorна 5000 рядків з 30 залежностями. Ім’я звучить “однією відповідальністю” (оркестрація), але це маскування. Рішення: Використовуйте метрику кількості залежностей (Dependency Count — підрахунок числа класів, від яких залежить даний клас). Якщо клас залежить від 10+ інших класів — це God Class. -
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 — розділення методів на команди, що змінюють стан, і запити, що тільки читають дані): Розділяйте класи, що змінюють стан, і класи, що тільки читають. Це природне розділення відповідальності.
Future Trends
- 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 (божественний об’єкт)]]