Дробить, не раскалывая
У SRP есть золотая середина. При перегибе он дробит один связный концепт в туман крошечных классов, которые приходится собирать в голове, — божественный класс наизнанку. Дроби по реальным границам изменений (независимый ритм, разные акторы); держи вместе то, что меняется вместе.
Ты научился разбивать божественный класс — тот OrderService на 2000 строк, который делал и ценообразование, и сохранение, и письма, и генерацию PDF. И ты разбил. И ещё разбил. И ещё. Теперь placeOrder трогает OrderValidator, OrderPriceCalculator, OrderTaxApplier, OrderTotalSummariser, OrderRepository, OrderEventEmitter и OrderMapper — восемь файлов, чтобы проследить один запрос, и каждый — тонкая обёртка, которая выполняет один оператор и делегирует дальше. Прочитать это никто не может. Чтобы понять единственный заказ, ты собираешь всю картину в голове, файл за файлом.
Ты обменял один провал на его зеркальное отражение. Божественный класс — это слишком мало дробления; этот туман осколков — слишком много. И то и другое — провалы SRP, и лекарство от одного не есть «больше того же лекарства». SRP оптимизирует связность, а у связности есть золотая середина. Этот урок — о том, как её найти.
После этого урока ты можешь сформулировать SRP как «одна причина для изменения», а не «один метод», и пользоваться этим, чтобы верно отмерить дробление; решать, когда дробить, по двум конкретным тестам — независимый ритм изменений и разные акторы — и когда держать вместе по общему состоянию и совместному изменению; распознавать взрыв числа классов (запах переприменённого SRP) как нечто отличное от божественного класса; и защищать дробление, указывая на реальную, наблюдаемую границу изменений, а не на догматичное правило.
SRP — про причины для изменения, а не про размер. Принцип часто неверно цитируют как «класс должен делать одну вещь», что подталкивает считать операторы и дробить, пока у каждого класса не останется один метод. Настоящая формулировка точнее: у модуля должна быть одна причина для изменения — или, что то же самое, он должен отвечать перед одним актором. «Актор» — это источник изменения: заинтересованная сторона, политика, протокол, который эволюционирует по своим часам.
// Классический пример Роберта Мартина: три актора прячутся в одном классе.
class Employee {
calculatePay() { /* меняется, когда меняется CFO / политика расчёта зарплаты */ }
reportHours() { /* меняется, когда меняется COO / операционная отчётность */ }
save() { /* меняется, когда меняется DBA / схема БД */ }
}Эти три метода обслуживают трёх разных акторов, которые меняются по несвязанным причинам. Именно это — а не число строк — причина, почему Employee следует разделить. Правильное число частей — это число независимых причин для изменения, не больше и не меньше. Размер — это симптом, который ты исследуешь; он не само правило.
Дроби, когда у двух обязанностей действительно независимый ритм изменений. Самый надёжный сигнал — наблюдаемый, а не воображаемый: меняются ли эти две вещи в разное время, под разные запросы? Если правила ценообразования меняются раз в квартал вместе с финансовой командой, а шаблон письма — всякий раз, когда у маркетинга кампания, то они на разных часах. Держать их в одном классе — значит каждая правка маркетинга рискует логикой ценообразования, а каждый пересмотр цен волочёт за собой вёрстку письма.
// Разные часы → дробим. PricingPolicy меняется по ритму финансов;
// ReceiptTemplate меняется по ритму маркетинга. Они никогда не движутся вместе.
class PricingPolicy { totalFor(cart: Cart): Money { /* этим владеют финансы */ } }
class ReceiptTemplate { render(order: Order): Html { /* этим владеет маркетинг */ } }Тест эмпирический: посмотри историю git. Если две области файла появляются в коммитах по разным причинам — это реальный шов. Дробление вдоль него означает, что будущие изменения останутся локальными: правка финансов трогает только PricingPolicy. Дробление поперёк него (по часам, которые ты выдумал) ничего не даёт и стоит косвенности.
Держи вместе то, что всегда меняется вместе и делит состояние. Обратный тест не менее важен и куда чаще игнорируется. Два куска логики, которые всегда попадают в один коммит, разделяют одни инварианты, читают и пишут одни и те же поля, — это одна обязанность под двумя именами методов. Разделить их — значит разбросать единый концепт по файлам и заставить каждое изменение прыгать между ними.
// Связно: и validate, и apply обеспечивают ОДИН инвариант
// (баланс никогда не уходит в минус) над ОДНИМ полем. Они всегда
// меняются вместе. Разбить их на классы Validator + Applier означало бы,
// что инвариант живёт в двух файлах, которые надо держать в синхроне.
class Account {
#balance: Money;
withdraw(amount: Money): void {
if (amount.gt(this.#balance)) throw new InsufficientFunds();
this.#balance = this.#balance.minus(amount); // то же поле, то же правило
}
}Когда ты дробишь это, изменение правила овердрафта теперь означает правку AccountValidator и AccountApplier и удержание их согласованными — ты изготовил ту самую дробовую хирургию, которой пытался избежать. Конкассентная логика (то, что должно меняться вместе, чтобы оставаться корректным) принадлежит вместе. Высокая связность и есть цель; SRP — лишь один рычаг на неё.
Взрыв числа классов — это провал SRP, а не успех SRP. Когда дробление порождает классы, у которых нет независимой причины для изменения, — Validator и Applier, которые всегда правятся в одном PR, Mapper, существующий лишь чтобы перенести три поля, — ты понизил связность, а не повысил. Цена реальна, и платят её при каждом чтении: косвенность без информации. Чтобы ответить на «что делает оформление заказа?», ты теперь открываешь восемь файлов и держишь их взаимодействие в рабочей памяти, потому что поведение раскололи между ними без границы, которая хоть что-то значит.
// Переприменённый SRP: одна связная операция атомизирована в делегирующие оболочки.
class OrderValidator { validate(o: Order) { /* один if */ } }
class OrderPriceApplier { apply(o: Order) { /* одна строка, зовёт Calculator */ } }
class OrderTotaller { total(o: Order) { /* одна сумма */ } }
// placeOrder() теперь оркеструет семь таких. Чтение его — это пересборка.Senior-ход — распознать обе формы как один и тот же дефект — неверную связность, — направленный в противоположные стороны. Божественный класс — это много причин для изменения, запиханных в одну коробку; взрыв числа классов — одна причина для изменения, размазанная по многим коробкам. Ни то ни другое не отображает границу модуля на реальную границу изменений. SRP, сделанный хорошо, садится между ними: столько модулей, сколько требуют разные акторы, и не меньше.
Один генератор отчёта, дроблённый тремя способами — реален лишь один разрез. Начнём с класса, который строит отчёт о продажах:
class SalesReport {
// (a) какие строки включать — меняется, когда финансы переопределяют «продажу»
private select(orders: Order[]): Order[] { /* политика финансов */ }
// (b) бегущие итоги — те же инварианты, те же поля, что и (a)
private subtotal(rows: Order[]): Money { /* всегда правится вместе с (a) */ }
// (c) рендеринг в HTML — меняется, когда дизайн обновляет шаблон
private toHtml(rows: Order[], total: Money): Html { /* политика дизайна */ }
}Раскалывающий разрез (неверный). Догматичное прочтение порождает пять классов: RowSelector, Subtotaller, TotalFormatter, HtmlRenderer, ReportOrchestrator. Но select и subtotal всегда меняются вместе — переопределение «продажи» меняет и то, какие строки идут в счёт, и то, как они суммируются. Они делят один финансовый инвариант. Разделить их — значит, что каждое изменение финансов теперь правка в два файла, удерживаемая в синхроне вручную. Ты создал дробовую хирургию и добавил оркестратор, которого никто не просил.
Сбалансированный разрез (верный). Акторов здесь ровно два, поэтому и модулей два:
// Актор «финансы»: что считается продажей и как она суммируется. Одна причина для изменения.
class SalesFigures {
compute(orders: Order[]): { rows: Order[]; total: Money } {
const rows = this.select(orders);
return { rows, total: this.subtotal(rows) };
}
private select(orders: Order[]): Order[] { /* … */ }
private subtotal(rows: Order[]): Money { /* … */ }
}
// Актор «дизайн»: как цифры представлены. Другая причина для изменения.
class SalesReportView {
render(figures: { rows: Order[]; total: Money }): Html { /* … */ }
}select и subtotal остаются вместе, потому что они — одна обязанность (финансовые цифры), сохраняя связность. Рендеринг отделяется, потому что отвечает другому актору с другим ритмом. Две реальные границы изменений — два модуля. Правка финансов трогает только SalesFigures; обновление шаблона трогает только SalesReportView; ни одно не тревожит другое, и никто не пересобирает пять осколков, чтобы понять один отчёт. Разрез следует за наблюдаемой реальностью, а не за сводом правил.
▸Почему это работает
Почему «разные акторы / разный ритм» — тест лучше, чем «делает ли оно больше одной вещи»? Потому что всё делает больше одной вещи, если приблизить достаточно сильно — withdraw проверяет баланс и вычитает. Разложенный бесконечно, каждый метод — это «несколько обязанностей», и потому наивное прочтение упирается в взрыв числа классов. «Причина для изменения» обрывает этот регресс: ты перестаёшь дробить, когда оставшиеся части всегда менялись бы вместе, потому что нет будущего изменения, которому захотелось бы их разлучить. Граница задаётся изменением, которое конечно и наблюдаемо (читай историю коммитов), а не поведением, которое дробится без конца. Именно поэтому SRP — экономический принцип, а не эстетический: это снова стоимость изменения, теперь применённая к границам модулей.
▸Частая ошибка
Ловушка догматичного разреза: считать «один класс — один метод» или «один файл — один глагол» целью и механически рефакторить к ней. Признак — слой классов на -er: Validator, Mapper, Applier, Orchestrator, — у которых нет независимой причины для изменения и которые всегда появляются в коммитах вместе. Второй признак: чтобы проследить одну фичу, ты постоянно прыгаешь между файлами, каждый из которых выполняет один оператор и делегирует. Если ты не можешь ответить на вопрос «какое отдельное будущее изменение тронет этот модуль, но не его соседа?», разрез выдуман, и тебе следует слить осколки обратно в связный концепт, из которого их вырезали. Дробление не бесплатно; каждая добавленная граница — это косвенность, за пересечение которой будущий читатель платит. Добавляй границу только там, где реальная граница изменений уже существует.
Коллега разбил класс Account на AccountValidator (проверяет, что баланс не превышен) и AccountApplier (вычитает сумму). В истории git каждое изменение правила овердрафта правит оба файла вместе. Это хорошее дробление по SRP?
SRP оптимизирует связность, а у связности есть золотая середина с формой провала по каждую сторону: божественный класс запихивает много акторов в один модуль, тогда как взрыв числа классов размазывает одного актора по многим. И то и другое — один и тот же дефект (границы модулей, не совпадающие с реальными границами изменений), направленный в противоположные стороны, поэтому лекарство от одного никогда не сводится к «больше дробления». Надёжные тесты — про изменение, а не про размер: дроби, когда у двух обязанностей независимый ритм изменений или они отвечают разным акторам; держи вместе то, что всегда меняется вместе и делит состояние и инварианты. Читай историю коммитов — реальный шов проявляется как области, меняющиеся по разным причинам. Целься в столько модулей, сколько требуют разные акторы, и не меньше; защищай каждый разрез, называя то отдельное будущее изменение, которое он удешевляет, и сливай любой осколок, который так защитить не можешь.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.