S - Single Responsibility: один модуль - одна причина меняться

S - Single Responsibility: один модуль - одна причина меняться

SRP: одна сущность - одна причина меняться.

Это тот же принцип, что и «маленькие функции, один смысл» - только на уровне модулей и пакетов. Если модуль меняется по 5 причинам, он будет ломаться по 5 причинам. И что хуже - каждое изменение ради одной причины способно тихо повредить логику для четырёх других, потому что весь код живёт в одном файле, видит общее состояние и делит зависимости.

«Причина меняться» = actor

Uncle Bob уточнил формулировку: причина изменения - это actor, то есть человек, отдел или роль, которая инициирует правки. Вернёмся к примеру: запросы на изменение валидации поступают от продакта, требования к хешированию диктует security-команда, схему БД меняют DBA, а текст и канал доставки приветственного письма правит маркетинг.

Если все эти роли «приходят» в один модуль, конфликт неминуем: правки security-команды могут случайно сломать сценарий маркетинга, и наоборот. SRP формализует простое правило: код, отвечающий перед разными actors, должен жить в разных модулях.

Плохой пример

type UserService struct {
  db *sql.DB
}

func (s *UserService) Register(email, pass string) error {
  // 1) валидация
  // 2) хеширование
  // 3) запись в БД
  // 4) отправка письма
  // 5) логирование/аналитика
  return nil
}
final class UserService {
  public function register(string $email, string $pass): void {
    // validate, hash, save, mail, log - всё здесь
  }
}
class UserService {
  #db;
  constructor(db) { this.#db = db; }

  async register(email, pass) {
    // 1) валидация
    // 2) хеширование
    // 3) запись в БД
    // 4) отправка письма
    // 5) логирование/аналитика
  }
}

Лучше: разделить обязанности

Один комбайн UserService против пяти отдельных модулей по actor

type UserRepo interface { Create(email, hash string) error }
type PasswordHasher interface { Hash(p string) (string, error) }
type Mailer interface { SendWelcome(email string) error }

type RegisterUser struct {
  repo   UserRepo
  hasher PasswordHasher
  mailer Mailer
}

func (uc *RegisterUser) Execute(email, pass string) error {
  if email == "" || len(pass) < 8 {
    return fmt.Errorf("invalid input")
  }
  hash, err := uc.hasher.Hash(pass)
  if err != nil { return err }
  if err := uc.repo.Create(email, hash); err != nil { return err }
  _ = uc.mailer.SendWelcome(email) // письмо не должно ломать регистрацию
  return nil
}
interface UserRepo
{
    public function create(string $email, string $hash): void;
}

interface PasswordHasher
{
    public function hash(string $pass): string;
}

interface Mailer
{
    public function sendWelcome(string $email): void;
}

final class RegisterUser
{
    public function __construct(
        private readonly UserRepo $repo,
        private readonly PasswordHasher $hasher,
        private readonly Mailer $mailer,
    ) {}

    public function execute(string $email, string $pass): void
    {
        if ($email === '' || strlen($pass) < 8) {
            throw new InvalidArgumentException('Неверный ввод');
        }
        $hash = $this->hasher->hash($pass);
        $this->repo->create($email, $hash);
        try {
            $this->mailer->sendWelcome($email);
        } catch (Throwable) {
        }
    }
}
/** @typedef {{ create: (email: string, hash: string) => Promise<void> }} UserRepo */
/** @typedef {{ hash: (pass: string) => Promise<string> }} PasswordHasher */
/** @typedef {{ sendWelcome: (email: string) => Promise<void> }} Mailer */

class RegisterUser {
  #repo;
  #hasher;
  #mailer;

  /**
   * @param {UserRepo} repo
   * @param {PasswordHasher} hasher
   * @param {Mailer} mailer
   */
  constructor(repo, hasher, mailer) {
    this.#repo = repo;
    this.#hasher = hasher;
    this.#mailer = mailer;
  }

  async execute(email, pass) {
    if (!email || pass.length < 8) {
      throw new Error('Неверный ввод');
    }
    const hash = await this.#hasher.hash(pass);
    await this.#repo.create(email, hash);
    // письмо не должно ломать регистрацию
    try { await this.#mailer.sendWelcome(email); } catch { /* ignore */ }
  }
}
SRP про обязанности. Можно иметь функцию на 30 строк, если она делает один смысл.

SRP и cohesion: два взгляда на одну идею

В литературе по проектированию модулей с 1970-х есть термин cohesion (связность) - насколько элементы внутри одного модуля «про одно и то же». SRP - это операционное правило, как добиться высокой связности на практике.

Низкая связность выглядит так: в одном пакете лежат validators.go, email_templates.go, db_queries.go, metrics.go - связаны только тем, что «нужны для регистрации». Каждый файл интересен разной части команды, и каждый меняется по своей причине.

Высокая связность - пакет registration содержит use case RegisterUser и его непосредственные интерфейсы (UserRepo, Mailer, PasswordHasher), а реализации этих интерфейсов лежат в infra/postgres, infra/smtp, infra/bcrypt. У каждой группы кода - один actor, один повод меняться.

SRP на уровне Go-пакета

В Go SRP работает не только на уровне структуры, но и на уровне пакета. Признаки нарушения SRP в пакете:

  • Имя пакета - общее (utils, helpers, common, manager) - это всегда красный флаг
  • Пакет импортирует одновременно database/sql, net/http и text/template - три разных мира в одном месте
  • Файлы пакета не связаны общим словарём предметной области

Здоровый пакет читается как «он умеет вот это и про это», а не «тут лежит всё, что не пристроилось в другие места».

Реалистичный антипаттерн: «UserService на 8 методов»

Часто SRP нарушается не явным комбайном, а тихим разрастанием:

type UserService struct { /* db, mailer, redis, ... */ }

func (s *UserService) Register(...) error                 { /* */ }
func (s *UserService) Login(...) (string, error)          { /* token */ }
func (s *UserService) ResetPassword(...) error            { /* */ }
func (s *UserService) UpdateProfile(...) error            { /* */ }
func (s *UserService) UploadAvatar(...) error             { /* S3 */ }
func (s *UserService) ExportUserData(...) ([]byte, error) { /* GDPR */ }
func (s *UserService) DeleteAccount(...) error            { /* */ }
func (s *UserService) SendNewsletter(...) error           { /* массовая рассылка */ }
<?php
declare(strict_types=1);

final class UserService
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly MailerInterface $mailer,
        private readonly \Redis $redis,
        // ...
    ) {}

    public function register(string $email, string $pass): void { /* */ }
    public function login(string $email, string $pass): string { /* token */ return ''; }
    public function resetPassword(string $email): void { /* */ }
    public function updateProfile(int $userId, array $patch): void { /* */ }
    public function uploadAvatar(int $userId, string $path): void { /* S3 */ }
    public function exportUserData(int $userId): string { /* GDPR */ return ''; }
    public function deleteAccount(int $userId): void { /* */ }
    public function sendNewsletter(string $subject, string $body): void { /* массовая рассылка */ }
}
class UserService {
  #em;
  #mailer;
  #redis;

  constructor(em, mailer, redis) {
    this.#em = em;
    this.#mailer = mailer;
    this.#redis = redis;
  }

  async register(email, pass) { /* */ }
  async login(email, pass) { /* token */ return ''; }
  async resetPassword(email) { /* */ }
  async updateProfile(userId, patch) { /* */ }
  async uploadAvatar(userId, path) { /* S3 */ }
  async exportUserData(userId) { /* GDPR */ return ''; }
  async deleteAccount(userId) { /* */ }
  async sendNewsletter(subject, body) { /* массовая рассылка */ }
}

Здесь сидит минимум четыре actor: безопасность (Login/Reset), профиль (Update/Avatar), GDPR-комплаенс (Export/Delete), маркетинг (Newsletter). Лечение - три-четыре отдельных use case с собственными зависимостями: Authentication, Profile, Compliance, Marketing (механика выноса - Extract Function/Method). Каждый можно тестировать, разворачивать и менять независимо.

Где SRP - это over-engineering

SRP - инструмент против реального конфликта обязанностей, а не цель сама по себе. Чрезмерное разделение приносит свои проблемы: код становится размазанным, навигация - длинной, а простые изменения требуют правок в трёх файлах вместо одного.

Признаки, что SRP «перегнули»:

  • В пакете 12 файлов по 30 строк, и каждый «отвечает за что-то одно», но всех их меняет один и тот же продакт под одну и ту же фичу
  • Чтобы понять простой CRUD, нужно прочитать 5 классов: UserCreator, UserUpdater, UserDeleter, UserGetter, UserLister - хотя actor у них один (продакт CRUD-формы)
  • Каждый метод оборачивается в отдельный класс «потому что Uncle Bob так велел»
// Над-инжиниринг: один actor (продакт CRUD), а классов пять
type UserCreator struct{ repo UserRepo }
func (c *UserCreator) Create(...) error { /* */ }

type UserUpdater struct{ repo UserRepo }
func (u *UserUpdater) Update(...) error { /* */ }

type UserGetter struct{ repo UserRepo }
func (g *UserGetter) Get(...) (*User, error) { /* */ }
// ...

// Достаточно: один use case с понятным API
type UserService struct{ repo UserRepo }
func (s *UserService) Create(...) error      { /* */ }
func (s *UserService) Update(...) error      { /* */ }
func (s *UserService) Get(...) (*User, error) { /* */ }
<?php
declare(strict_types=1);

// Над-инжиниринг: один actor (продакт CRUD), а классов пять
final class UserCreator
{
    public function __construct(private readonly UserRepo $repo) {}
    public function create(array $data): void { /* */ }
}

final class UserUpdater
{
    public function __construct(private readonly UserRepo $repo) {}
    public function update(int $id, array $patch): void { /* */ }
}

final class UserGetter
{
    public function __construct(private readonly UserRepo $repo) {}
    public function get(int $id): User { /* */ throw new \LogicException(); }
}

// Достаточно: один use case с понятным API
final class UserService
{
    public function __construct(private readonly UserRepo $repo) {}

    public function create(array $data): void { /* */ }
    public function update(int $id, array $patch): void { /* */ }
    public function get(int $id): User { /* */ throw new \LogicException(); }
}
// Над-инжиниринг: один actor (продакт CRUD), а классов пять
class UserCreator {
  #repo;
  constructor(repo) { this.#repo = repo; }
  async create(data) { /* */ }
}

class UserUpdater {
  #repo;
  constructor(repo) { this.#repo = repo; }
  async update(id, patch) { /* */ }
}

class UserGetter {
  #repo;
  constructor(repo) { this.#repo = repo; }
  async get(id) { /* */ }
}

// Достаточно: один use case с понятным API
class UserService {
  #repo;
  constructor(repo) { this.#repo = repo; }
  async create(data) { /* */ }
  async update(id, patch) { /* */ }
  async get(id) { /* */ }
}

Правило проверки: SRP применяется, когда у методов разные actors. Если все методы меняются по запросам одной и той же команды, ради одной и той же предметной области - оставь их вместе. Высокая когезия здесь полезнее, чем формальное «один класс - одна операция».

Признаки нарушения SRP в код-ревью

Когда смотришь чужой PR, эти сигналы помогают быстро заподозрить проблему:

  • В описании коммита приходится перечислять несвязанные изменения через «и» («поправил валидацию и обновил шаблон письма»)
  • В импортах структуры одновременно встречаются database/sql, net/smtp и text/template
  • Тесты на класс требуют моков для трёх и более внешних систем
  • Имя класса заканчивается на Manager, Helper, Util, Service без прилагательного - общее имя обычно скрывает несколько ответственностей
  • Файл с компонентом начинает выходить за 300-400 строк без видимой причины
  • Команды и продакты приходят в один и тот же модуль с конкурирующими запросами
  • В git blame на одном файле видно правки от 5+ разных авторов из несвязанных команд
  • Pull request в этот файл регулярно требует одобрения людей, не работающих над одной задачей

Эти признаки - не приговор. Они повод задать вопрос: «какие actors сюда приходят с правками?». Если ответ - один (например, продакт CRUD-формы), всё в порядке. Если несколько - перед нами кандидат на разделение.

Мини‑задание

  • Возьми один «комбайн»-метод и выпили из него хотя бы 2 ответственности в отдельные функции/модули
  • Открой свой проект и найди структуру с 5+ методами - выпиши, какие actors могут попросить эти методы поменять
  • Если actors разные - попробуй мысленно разделить структуру на два независимых use case

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.