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) логирование/аналитика
}
}
Лучше: разделить обязанности
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 и 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