Одна причина для изменения
SRP — это не «делай одно». У модуля должна быть одна причина для изменения: он отвечает перед одним актором, чьи меняющиеся требования вынуждают правки. Слияние акторов (Финансы + HR + DBA) в один класс — это нарушение; разделяй по актору.
Финансы просят изменить, как считается оплата сверхурочных. Ты правишь Employee.calculatePay(), выкатываешь это, а через неделю HR заводит баг: отчёт по часам на их дашборде теперь слегка неверен. Отчёта ты не трогал. Но два метода делили общий хелпер, и твоя правка «оплаты» протекла в «часы». Два отдела, один класс, одна авария, ждущая своего часа.
Именно этот провал и призван предотвратить принцип единственной ответственности — и почти все сначала учат SRP как неправильное правило. «Класс должен делать одно дело» звучит верно и почти бесполезно: оно достаточно расплывчато, чтобы оправдать любое разделение и любое слияние. Настоящий принцип острее, и он про то, кто может заставить тебя изменить код.
После этого урока ты можешь сформулировать SRP в точной формулировке дяди Боба — у модуля должна быть ровно одна причина для изменения, где «причина» — это актор, чьи требования диктуют правки; определить акторов, стоящих за методами класса, и распознать, когда несколько акторов связаны в один модуль; разделить класс по актору, а не по глаголу; и избежать обратного провала — измельчения класса на однометодные фрагменты из-за неверного прочтения SRP как «один публичный метод на класс».
SRP — про одну причину для изменения, а не про одно сделанное дело. Собственная поправка Роберта Мартина пряма: принцип никогда не был «модуль должен делать одно дело». Эта народная версия — то, что помнит большинство, и именно поэтому про SRP спорят бесконечно — у вопроса «считаются ли парсинг и валидация за одно дело или за два?» нет ответа. Реальная формулировка: у модуля должна быть одна, и только одна, причина для изменения. «Причина для изменения» — это не фича и не метод; это источник требований — человек или роль, которые могут прийти и потребовать, чтобы поведение было другим.
// "Does this do one thing?" — unanswerable, and the wrong question.
// "Who can ask me to change this?" — answerable, and the right one.Сдвиг от подсчёта глаголов к определению заказчиков — это весь урок целиком.
«Причина для изменения» — это актор. Мартин заостряет «причину для изменения» в актора: группу пользователей или стейкхолдеров, ради которых делается данное изменение. Финансы определяют, как считается оплата. HR определяет, что считается отчётными часами. Команда DBA определяет схему хранения. Каждый — отдельный актор с отдельными требованиями, которые дрейфуют по собственному расписанию. Каноническое нарушение — один класс, обслуживающий всех трёх:
class Employee {
calculatePay(): Money { /* Finance owns this rule */ }
reportHours(): Hours { /* HR owns this rule */ }
save(): void { /* DBA owns this schema */ }
}Три актора. Три причины для изменения. SRP говорит: модуль, отвечающий перед тремя акторами, — это три обязанности под одним именем.
Опасность не в уродстве — она в случайной связанности между акторами. Причина, по которой это принцип, а не стилистическая заметка, — тот провал, который он предотвращает. Когда два актора делят класс, они склонны делить и приватные хелперы, и изменение, запрошенное одним актором, молча меняет поведение, на которое полагается другой актор.
class Employee {
// Both pay and the HR report call this. Finance "owns" it conceptually.
private regularHours(): Hours { /* shared helper */ }
calculatePay(): Money { return rate.times(this.regularHours()); } // Finance
reportHours(): Hours { return this.regularHours(); } // HR reads the SAME helper
}Финансы просят изменить, как regularHours() округляет, по расчётным причинам. Ты вносишь правку. Отчёт HR — у которого не было причин меняться — теперь показывает другие числа. Этого никто не просил. Вот катастрофа, от которой страхует SRP: требование Финансов дотягивается и ломает фичу HR, потому что они были сварены вместе. Связанность невидима, пока не выкатит неверный отчёт.
Разделяй по актору: один модуль на источник требований. Решение — не «сделать каждый метод отдельным классом». Оно в том, чтобы отделить данные от актор-специфичного поведения, чтобы каждая политика жила с актором, который ею владеет, и менялась только когда меняется этот актор.
class EmployeeData { /* just the fields; no business rules */ }
class PayCalculator { calculatePay(e: EmployeeData): Money { /* Finance only */ } }
class HoursReporter { reportHours(e: EmployeeData): Hours { /* HR only */ } }
class EmployeeRepository { save(e: EmployeeData): void { /* DBA only */ } }Теперь изменение Финансов трогает PayCalculator и на этом останавливается. Если оплате и отчётности действительно нужен один и тот же расчёт «обычных часов», эта общая логика должна быть перенесена осознанно в собственное владеемое место — а не оставлена приватным хелпером, который два актора случайно вызывают. Тест на «это одна обязанность?» таков: могут ли два разных актора запросить конфликтующие изменения к ней? Если да, это как минимум две обязанности, независимо от того, как мало в ней методов.
Связанность срабатывает — затем разделение её сдерживает. До: один класс, три актора, общий приватный хелпер.
class Employee {
constructor(private timecards: Timecard[], private rate: Money) {}
// shared by pay AND the HR report
private regularHours(): number {
// round each shift DOWN to the quarter hour
return this.timecards.reduce((h, t) => h + Math.floor(t.hours * 4) / 4, 0);
}
calculatePay(): Money { return this.rate.times(this.regularHours()); } // Finance
reportHours(): number { return this.regularHours(); } // HR
}Финансы просят: «Перестаньте округлять оплату вниз — округляйте до ближайшей четверти, расчётка недоплачивала». Ты меняешь Math.floor на Math.round внутри regularHours(). Расчётка исправлена. Но reportHours() вызывает тот же хелпер, поэтому комплаенс-отчёт HR — который по закону обязан округлять вниз — теперь округляет до ближайшего. Одна правка Финансов, одна сломанная фича HR, ноль запросов от HR. Это детонирует связанность акторов.
После: разделено по актору, и общая логика принадлежит явно, а не делится случайно.
class EmployeeData {
constructor(readonly timecards: Timecard[], readonly rate: Money) {}
}
// Finance owns its own rounding rule
class PayCalculator {
pay(e: EmployeeData): Money {
const hours = e.timecards.reduce((h, t) => h + Math.round(t.hours * 4) / 4, 0);
return e.rate.times(hours);
}
}
// HR owns its own rounding rule — and it can never be changed by a Finance edit
class HoursReporter {
report(e: EmployeeData): number {
return e.timecards.reduce((h, t) => h + Math.floor(t.hours * 4) / 4, 0);
}
}Теперь «округление до ближайшего» от Финансов — однострочная правка внутри PayCalculator, а HoursReporter физически неспособен быть ею затронут. Правила двух акторов разошлись — что им как раз всегда и было позволено. SRP добавил классы не ради опрятности; он дал каждому актору стену.
▸Почему это работает
Почему формулировать правило как «причина для изменения», а не просто «группируй связанные методы»? Потому что связность-по-теме и связность-по-актору расходятся ровно там, где это важно. calculatePay, reportHours и save — все «про сотрудника»; тематически они принадлежат вместе, и поэтому плохой дизайн ощущается естественным. SRP перекрывает тематическую группировку группировкой по изменению: держи вместе то, что меняется вместе по одной и той же причине, и разделяй то, что меняется по разным причинам, даже если это про одно и то же существительное. Единица связности — актор, а не сущность. Это senior-переосмысление — ты перестаёшь рисовать границы классов вокруг данных и начинаешь рисовать их вокруг того, кто может потребовать изменения.
▸Частая ошибка
Обратная чрезмерная коррекция так же неверна: прочесть SRP как «один публичный метод на класс» и измельчить всё на PayCalculatorForFullTimeEmployees, PayCalculatorForContractors, RegularHoursRounder, OvertimeRounder… Это анемичная фрагментация: десятки однометодных классов, которые все меняются вместе всякий раз, когда меняются Финансы, теперь размазанные по десяти файлам с логикой, расплёсканной между ними. Ты размножил площадь поверхности, не сократив числа причин для изменения — на деле ты сделал единую обязанность Финансов труднее для правки. SRP удовлетворён, когда у каждого модуля одна причина для изменения, а не когда у каждого модуля один метод. Если два куска всегда меняются ради одного актора, они принадлежат одному модулю; разделение их нарушает SRP так же верно, как и слияние двух акторов.
У класса всего ОДИН публичный метод, generateInvoice(), но внутри он считает налог (правило Финансов), форматирует валюту для UI (вопрос дизайна/локали) и пишет строку в таблицу леджера (схема DBA). Удовлетворяет ли он SRP?
SRP — это не «делай одно дело» — это одна причина для изменения, где причина — это актор: источник требований, который может потребовать, чтобы код был другим. Каноническое нарушение — класс Employee, владеющий calculatePay() (Финансы), reportHours() (HR) и save() (DBA) — три актора, сваренные в один модуль, так что изменение, запрошенное одним, может сломать фичу, на которую полагается другой, через общий хелпер. Решение — разделить по актору, дав каждому источнику требований свой модуль и свою стену, чтобы радиус поражения изменения останавливался на акторе, который его попросил. Страхуй оба края: слияние акторов связывает несвязанные изменения, но измельчение логики одного актора на однометодные классы — тот же грех наизнанку: анемичная фрагментация, которая размножает файлы, не сокращая причин для изменения. Единица ответственности — это актор.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.