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

Наведіть приклад порушення принципу Single Responsibility

Уявіть шеф-кухаря елітного ресторану, який одночасно: 4. Миє підлогу в залі для відвідувачів.


🟢 Junior Level

Порушення принципу Single Responsibility (SRP) виникає тоді, коли клас бере на себе відповідальність за кілька непов’язаних завдань, змушуючи його змінюватися через різні, незалежні одна від одної причини.

Клас-порушник (God Object / Swiss Army Knife):
┌────────────────────────────────────────────────────────┐
│                   OrderController                      │
│ ├── parseAndValidateJson()  ──► Робота з HTTP/JSON     │
│ ├── calculateDiscounts()    ──► Бізнес-логіка знижок   │
│ ├── executeSqlInsert()      ──► Доступ до даних / SQL  │
│ └── sendSmsViaTwilio()      ──► Інтеграція з SMS-шлюзом│
└────────────────────────────────────────────────────────┘

Наочний приклад із життя:

Уявіть шеф-кухаря елітного ресторану, який одночасно:

  1. Готує страви на кухні.
  2. Сам розвозить замовлення на мопеді містом.
  3. Веде податковий облік і здає бухгалтерську звітність.
  4. Миє підлогу в залі для відвідувачів.

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

Класичний антипатерн у Java:

// ПОГАНО: Один клас поєднує 4 різні відповідальності
public class Invoice {
    private Long id;
    private BigDecimal amount;

    // 1. Бізнес-логіка розрахунку податку
    public BigDecimal calculateTax() {
        return amount.multiply(new BigDecimal("0.20"));
    }

    // 2. Логіка персистентності (SQL / JDBC)
    public void saveToDatabase() {
        String sql = "INSERT INTO invoices (id, amount) VALUES (?, ?)";
        // Відкриття Connection, PreparedStatement, executeUpdate...
    }

    // 3. Форматування представлення (HTML / PDF)
    public String generatePdfReport() {
        return "<html><body><h1>Рахунок #" + id + "</h1></body></html>";
    }

    // 4. Комунікація / Сповіщення
    public void emailToCustomer(String email) {
        // Налаштування SMTP сесії, Transport.send...
    }
}

🟡 Middle Level

1. Реальні приклади порушення SRP у Spring Boot

Приклад 1: «Всеїдний» Spring @Service

Найпоширеніший антипатерн в enterprise-розробці — перетворення сервісу на безформне сміттєзвалище з 15 інжектованих репозиторіїв:

// ПОГАНО: GeneralUserService знає про все на світі
@Service
@RequiredArgsConstructor
public class GeneralUserService {

    private final UserRepository userRepo;
    private final OrderRepository orderRepo;
    private final EmailClient emailClient;
    private final PasswordEncoder encoder;
    private final S3Client s3Client;
    private final AuditLogger auditLogger;

    // Причина 1: Змінилися правила валідації паролів (безпека)
    public void registerUser(UserDto dto) { /* ... */ }

    // Причина 2: Змінився провайдер хмарного сховища S3 (DevOps)
    public void uploadAvatar(Long userId, byte[] image) { /* ... */ }

    // Причина 3: Змінився шаблон листа для скидання пароля (маркетинг)
    public void sendPasswordResetEmail(String email) { /* ... */ }

    // Причина 4: Бухгалтерія змінила формат експорту історії замовлень (фінанси)
    public byte[] exportOrderHistoryToExcel(Long userId) { /* ... */ }
}

Приклад 2: Поєднання JPA Entity та DTO в одному класі

// ПОГАНО: Сутність бази даних одночасно є DTO для REST API
@Entity
@Table(name = "users")
public class User {
    @Id
    private Long id;

    @Column(name = "db_password_hash")
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) // Деталі JSON-серіалізації!
    private String password;

    @Transient
    @Pattern(regexp = "^\\+?[0-9]{10,13}$") // Валідація вхідного HTTP-запиту!
    private String rawPhoneInput;
}

У чому помилка: Зміна контракту публічного фронтенду неминуче зачіпає структуру таблиці в PostgreSQL, і навпаки.


🔴 Senior Level

1. Руйнівні наслідки порушення SRP

Порушення SRP
    ├──► Крихкість (Fragility): Виправлення логіки відправки email ламає розрахунок знижок
    ├──► Нерухомість (Immobility): Неможливо перевикористати метод без затягування 15 чужих залежностей
    ├──► Конфлікти злиття (Merge Conflicts): 10 розробників одночасно правлять один God-клас
    └──► Вичерпання транзакційних ресурсів (Connection Starvation):

Небезпека Connection Starvation при порушенні SRP:

Якщо в одному транзакційному методі сервісу поєднано взаємодію з БД та повільний зовнішній HTTP-виклик:

@Transactional // Захоплює з'єднання з БД із пулу HikariCP!
public void processOrder(OrderDto dto) {
    Order order = orderRepo.save(new Order(dto)); // 1. Швидкий INSERT (2 мс)

    // 2. Виклик зовнішнього сервісу еквайрингу ПО МЕРЕЖІ всередині транзакції!
    // Якщо банк гальмує 5 секунд — потік усі 5 секунд утримує з'єднання з БД!
    bankPaymentGateway.charge(dto.getCard(), dto.getAmount()); 

    order.setStatus(OrderStatus.PAID);
    orderRepo.save(order);
}

При 100 паралельних запитах пул з’єднань бази даних (maximum-pool-size = 30) миттєво вичерпується. Уся база даних паралізована через те, що транзакційний метод порушив SRP і поєднав персистентність із повільним мережевим I/O.

2. Рефакторинг: Decorator замість роздування сервісу

Як додати до сервісу кешування, аудит та збір метрик без порушення SRP?

  • ❌ Погано: Впровадити MeterRegistry та CacheManager безпосередньо в OrderService (клас починає відповідати за бізнес-логіку + моніторинг + кеш).
  • ✅ Добре: Використати патерн Decorator (Декоратор):
// 1. Чистий контракт
public interface OrderService {
    Order createOrder(OrderRequest req);
}

// 2. Лише чиста бізнес-логіка (SRP)
@Service
@Primary
public class CoreOrderService implements OrderService {
    @Override
    public Order createOrder(OrderRequest req) {
        // Лише створення та перевірка бізнес-правил замовлення
        return new Order(req);
    }
}

// 3. Декоратор метрик: відповідає ВИКЛЮЧНО за телеметрію (SRP)
@Service
@RequiredArgsConstructor
public class TimedOrderServiceDecorator implements OrderService {
    private final OrderService delegate;
    private final MeterRegistry registry;

    @Override
    public Order createOrder(OrderRequest req) {
        return registry.timer("order.creation.time")
            .record(() -> delegate.createOrder(req));
    }
}

4 Tricky Questions

1. Як поєднання @Entity (JPA) та @JsonProperty (Jackson) в одному класі порушує SRP і до яких критичних проблем це призводить?

Відповідь: Клас бере на себе дві принципово різні ролі:

  1. Відображення на реляційну схему БД (Domain/Data model) — змінюється під час міграцій СУБД, оптимізації індексів, зв’язків @OneToMany.
  2. Публічний контракт зовнішнього API (Presentation/Transport model) — змінюється відповідно до вимог фронтенду чи мобільних клієнтів. Проблеми на практиці:
    • Витік конфіденційних даних (Over-fetching / Security Leak): Випадково додане в Entity поле passwordHash або internalAuditId автоматично витікає в JSON-відповідь клієнту, якщо розробник забув вказати @JsonIgnore.
    • Циклічна рекурсія: Двоспрямовані зв’язки (@ManyToOne та @OneToMany) призводять до фатального StackOverflowError під час серіалізації в JSON. Рішення: Суворий поділ на OrderEntity (для бази даних) та OrderResponseDto (для REST API) з мапінгом через MapStruct.

2. Що таке Characterization Tests (тести фіксації поведінки / Golden Master) і як вони застосовуються під час розпилу God Object?

Відповідь: Під час рефакторингу застарілого «божественного класу» на 5000 рядків модульні тести зазвичай повністю відсутні. Писати класичні Unit-тести важко, оскільки код заплутаний і містить численні приховані побічні ефекти. Методика Golden Master:

  1. Класу передається репрезентативний набір різноманітних вхідних параметрів (наприклад, 1000 різних запитів).
  2. Вихідні дані (стан бази даних, згенеровані файли, повернені структури) фіксуються у файлі-еталоні (Golden Master).
  3. Створюється автотест, який перевіряє, що за будь-яких внутрішніх перетворень код повертає точно зафіксований еталонний результат.
  4. Під надійним прикриттям Golden Master розробники безпечно екстрагують методи в окремі SRP-класи.

3. Чому в архітектурі CQRS поділ сутності на OrderCommandService та OrderQueryService вважається торжеством принципу SRP?

Відповідь: Читання (Query) та запис (Command) мають принципово різні вимоги:

  • Запис: Вимагає суворих бізнес-інваріантів, блокувань, транзакційної цілісності, нормалізації даних і валідації. Замовник вимог — експерти доменної області.
  • Читання: Вимагає максимальної швидкості, денормалізації, кешування, складних агрегацій та пагінації для UI. Замовник — UI/UX дизайнери та клієнтські застосунки. Поєднання їх в одному класі змушує його змінюватися як при додаванні фільтра на екрані каталогу, так і при зміні податкового законодавства. Поділ на Command і Query надає кожному сервісу рівно одну причину для зміни.

4. У чому різниця між Cohesion (внутрішньою зв’язністю) та Coupling (зовнішнім зачепленням) на прикладі класу, що порушує SRP?

Відповідь:

  • Cohesion (Внутрішня зв’язність): Показує, наскільки елементи всередині одного класу сфокусовані на досягненні єдиної мети. У класі-порушнику SRP внутрішня зв’язність є екстремально низькою (Low Cohesion): метод calculateSalary() жодним чином не пов’язаний із методом sendEmail(), вони оперують різними полями й не мають спільної задачі.
  • Coupling (Зовнішнє зачеплення): Показує, наскільки сильно клас залежить від інших компонентів системи. У порушника SRP зачеплення є екстремально високим (High Coupling): йому потрібні десятки зовнішніх залежностей (UserRepository, EmailSender, S3Client, PdfGenerator), і збій будь-якої з них паралізує весь клас.

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

Головне:

  • Порушення SRP виникає, коли клас виконує декілька різнорідних функцій і змінюється на вимогу різних стейкхолдерів.
  • Типові порушники: God Object, сервіси на 1000+ рядків, Entity з Jackson-анотаціями, методи зі змішуванням SQL та HTTP.
  • Найнебезпечніший наслідок у Spring: Connection Pool Starvation, якщо всередині методу з @Transactional виконується повільний виклик стороннього API через мережу.
  • Для наскрізної функціональності (логування, кешування, метрики) використовуйте Decorator або Spring AOP, а не роздувайте бізнес-сервіс.
  • Завжди розділяйте Entity (база) та DTO (API).
  • Ознаки порушення: конструктор із 10+ залежностями, у тестах понад 5 моків, заплутані if-else на всі випадки життя.

Червоні прапорці (Чого говорити НЕ МОЖНА):

  • ❌ «Поєднувати Entity і DTO в одному класі — це нормальна оптимізація, щоб не створювати зайві файли» (Призводить до витоку даних, StackOverflowError і жорсткої прив’язки бази до API).
  • ❌ «У методі з @Transactional нормально робити виклики сторонніх платіжних шлюзів по HTTP» (Блокує з’єднання з БД і призводить до колапсу пулу з’єднань).
  • ❌ «Якщо клас має 20 методів, він автоматично порушує SRP» (Якщо всі 20 методів сфокусовані на одній меті, наприклад BigDecimal, SRP не порушено).

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