📐 Раздел 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-тесты часто полностью отсутствуют. Писать классические 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, сервисы на 2000+ строк, Entity с Jackson-аннотациями, методы со смешением SQL и HTTP.
  • Опаснейшее последствие в Spring: Connection Pool Starvation, если внутри @Transactional вызывается медленный сторонний API.
  • Для добавления сквозной функциональности (логи, кэш, метрики) используйте Decorator или Spring AOP, а не раздувайте класс.
  • Разделяйте Entity (база) и DTO (API).
  • Признаки нарушения: конструктор с 10+ @Autowired, в тестах больше 5 моков, циклы if-else на все случаи жизни.

Красные флаги (Чего говорить НЕЛЬЗЯ):

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

Связанные темы: