Акторы и оси изменения
Компонент часто смешивает три независимые оси изменения — представление, бизнес-правило, хранение, — каждую запрашивает свой актор. Группируй код по оси, чтобы одно изменение трогало одно место. SRP бьёт по связанности осей; резать вымышленные оси — это лишняя косвенность.
«Одна ответственность» звучит как упражнение на подсчёт — пусть каждый класс делает что-то одно, — и на практике эта формулировка бесполезна, потому что любой полезный класс делает несколько вещей. Класс Report загружает данные, применяет правило и форматирует вывод: это три вещи, и всё же разбиение по «вещам» не даёт принципиального места, где остановиться. Считай глаголы — и можно оправдать любое число классов от одного до дюжины.
Версия, которая реально направляет проектирование, острее: у модуля должна быть одна причина меняться — один источник запросов на изменение. Способ найти эти причины — спросить, для каждой строки кода, кто просит её изменить и вдоль какой оси. Строки, отвечающие одному человеку и меняющиеся по одной причине, принадлежат вместе. Строки, отвечающие разным людям и меняющиеся независимо, — это связанность, которая ждёт момента укусить.
После этого урока ты можешь выявить независимые оси изменения в запутанном компоненте, спрашивая «кто запрашивает изменение вдоль этой оси?»; сгруппировать код так, чтобы изменение по одной оси трогало ровно один модуль; объяснить, почему это убивает shotgun surgery и почему связность («то, что меняется вместе, живёт вместе») — та же идея с другой стороны; и распознать режим отказа — разрезание вдоль осей, которые на деле никогда не варьируются независимо, что покупает косвенность и нулевую гибкость.
«Ось изменения» — это направление, вдоль которого требования двигаются независимо, и у каждой оси есть владелец, который её толкает. Забудь про подсчёт методов. Спроси вместо этого: какие виды запросов на изменение приходят на этот код и приходят ли они по отдельности? Сервис ценообразования обычно сталкивается как минимум с тремя: маркетинг меняет, как цены отображаются (валюта, локаль, округление); финансы меняют, как вычисляется налог или скидка (бизнес-правило); платформа/ops меняют, как результаты хранятся (схема, хранилище, колонки аудита). Эти три запроса приходят от трёх разных людей по трём разным графикам. Каждый — это ось. Владелец оси — «актор» в формулировке SRP у Дядюшки Боба — это тот, к кому ты бы пошёл спросить, когда такое изменение понадобится.
Смысл называть актора в том, что это делает границу объективной. «Является ли форматирование отдельной ответственностью от налоговой математики?» — вопрос вкуса. «Отличается ли человек, который меняет форматирование, от человека, который меняет налоговое правило?» — на это есть ответ, и в большинстве команд ответ «да».
Когда две оси делят один модуль, изменение по одной оси рискует сломать другую — это связанность между осями, и именно эту цену бьёт SRP. Рассмотри этот сервис, где политика представления, бизнес-правило и хранение живут в одном методе:
class InvoiceService {
render(invoice: Invoice): string {
// business rule (finance owns this)
const tax = invoice.subtotal * 0.2;
const total = invoice.subtotal + tax;
// persistence (ops owns this)
db.execute(
"UPDATE invoices SET total_cents = ? WHERE id = ?",
[Math.round(total * 100), invoice.id],
);
// presentation policy (marketing/locale owns this)
return `Total: $${total.toFixed(2)}`;
}
}Три актора теперь сплетены в одном методе. Когда финансы меняют налоговое правило, диф сидит рядом с SQL и $-форматированием — ревьюер от финансов вынужден читать код, которым не владеет, а небрежная правка правила может сдвинуть округление, записываемое в базу. Три заботы не просто сосуществуют; они делят состояние и порядок выполнения, поэтому каждая открыта ошибкам остальных.
Разделение по оси означает, что изменение по одной оси трогает ровно один модуль — это лекарство от shotgun surgery. Shotgun surgery — это запах, когда одно логическое изменение вынуждает править разбросанные по многим местам куски. Конкретное утверждение SRP в том, что разброс вызван смешением осей: если налоговая математика размазана по рендерингу, хранению и отчётам, то изменить налоговую математику — это shotgun. Вытяни каждую ось в свой шов — и разброс схлопывается:
// axis 1: the business rule — finance's module
class InvoicePricing {
total(invoice: Invoice): number {
const tax = invoice.subtotal * taxRate(invoice.region);
return invoice.subtotal + tax;
}
}
// axis 2: persistence — ops's module
class InvoiceStore {
saveTotal(id: string, totalCents: number) {
db.execute("UPDATE invoices SET total_cents = ? WHERE id = ?", [totalCents, id]);
}
}
// axis 3: presentation policy — locale's module
class InvoiceFormatter {
line(total: number, locale: Locale): string {
return format(total, locale); // currency, rounding, symbol position
}
}Теперь изменение налогового правила — одна правка внутри InvoicePricing; миграция хранилища — одна правка внутри InvoiceStore; изменение локали — одна правка внутри InvoiceFormatter. Ни одна из них не может сломать другие, потому что они больше не делят тело.
Связность — тот же принцип с другой стороны: то, что меняется вместе, живёт вместе; то, что меняется врозь, живёт врозь. SRP часто преподают как «разбей всё на части», но более глубокое правило — это группировка, и критерий группировки — со-вариация. Код, который всегда меняется по одной причине, обладает высокой связностью — держи его в одном месте, даже если это несколько функций. Код, который меняется по разным причинам, имеет низкую связность, когда насильно собран вместе, — это и есть та сплетённость выше. Так что SRP не велит делать модули маленькими; он велит делать причины-меняться каждого модуля единственными. Модуль на 200 строк, который меняется только когда меняется налоговое правило, более связен — и более соответствует SRP, — чем модуль на 20 строк, который меняется по причинам налога, форматирования и хранения.
Вот почему «одна ответственность», «одна ось изменения» и «высокая связность» — три имени одной проектной цели: выровнять границы модулей по границам между независимыми запросами на изменение.
Тест в действии: «если требование R меняется, сколько модулей мне придётся тронуть и рискуют ли сломаться непричастные?» Возьми смешанный InvoiceService.render из шага 2 и прогони через него три конкретных требования.
До (один смешанный метод):
- R1 — «налог теперь зависит от региона»: правишь
render. Диф сидит рядом с SQL и форматной строкой; ревьюер должен рассуждать о хранении и отображении, чтобы одобрить изменение финансов. Радиус поражения: непричастные оси открыты. - R2 — «переезд с MySQL на Postgres»: снова правишь
render— тот же метод, который только что трогали финансы. Два актора теперь спорят за один файл. - R3 — «показывать цены в EUR пользователям из ЕС»: правишь
renderв третий раз. Каждое требование, от каждого актора, протекает через один и тот же метод. Это shotgun, вывернутый в бутылочное горло.
После (разбито по оси):
- R1 → одна правка в
InvoicePricing.total.InvoiceStoreиInvoiceFormatterне открываются, поэтому сломаться не могут. - R2 → одна правка в
InvoiceStore.saveTotal. Ценообразование и форматирование нетронуты. - R3 → одна правка в
InvoiceFormatter.line. Ценообразование и хранение нетронуты.
Ответ на тест перешёл от «тронуть один метод, рискнуть тремя осями» к «тронуть один модуль, не рискнуть ничем больше». Заметь, что число правок на требование не упало — был один метод до и один модуль после. Изменилась изоляция: каждое изменение теперь заключено в ось, которая им владеет, поэтому непричастные требования больше не сталкиваются в одном дифе.
▸Почему это работает
Почему «кто запрашивает изменение», а не «что делает код»? Потому что то, что он делает, наблюдается в один момент и не даёт информации о том, как он будет двигаться. Две строки могут делать поверхностно похожее (обе «форматируют строку»), но отвечать разным акторам, которые меняют их по разным графикам, — а две строки, выглядящие несвязанными, могут всегда меняться вместе, потому что один актор владеет обеими. Вопрос про актора предсказывает со-вариацию, а это именно то, что границы модулей должны отслеживать. Привязка границ к акторам — это ещё и то, что делает SRP проверяемым на реальной команде: ты можешь буквально указать на двух людей, которые подали бы два запроса на изменение. Если ты не можешь назвать двух разных заказчиков для двух кусков кода, то у тебя, вероятно, не две ответственности — а одна, и разрезать её преждевременно.
▸Частая ошибка
Режим отказа — разрезание вдоль осей, которые на деле никогда не варьируются независимо. SRP — это нож, и злоупотребление им кромсает. Если ты разобьёшь InvoicePricing на TaxCalculator, DiscountCalculator, TotalAssembler, RoundingPolicy — но каждое изменение ценообразования в твоей истории трогало все четыре вместе, — ты изобрёл четыре модуля, которые меняются в унисон. Теперь одно концептуальное изменение снова стало shotgun surgery, только нанесённым самому себе: четыре файла, четыре конструктора, косвенность между ними и нулевая купленная гибкость, потому что воображённые оси никогда не были раздельными. Дисциплина режет в обе стороны: разделяй оси, которые доказуемо варьируются врозь (разные акторы, разная история изменений), и держи вместе те, что нет. Преждевременное разделение — такое же нарушение SRP, как и сплетённость, — просто оно проваливается на стороне связности, а не связанности.
Ты решаешь, стоит ли разбить класс Report, который загружает данные, применяет правило скидки и рендерит HTML. По SRP-как-оси, какой вопрос решающий — и что сделало бы разбиение НЕВЕРНЫМ решением?
«Одна ответственность» бессмысленна как подсчёт глаголов; она становится операциональной, когда читаешь её как одну причину меняться, а эти причины находишь, спрашивая для каждого куска кода кто запрашивает изменение и вдоль какой оси. Типичный компонент прячет несколько осей — политику представления, бизнес-правила, хранение, — каждой владеет свой актор, толкающий её по своему графику. Когда оси делят модуль, они связаны между собой: изменение по одной рискует сломать другие, а одно логическое изменение разлетается в shotgun surgery. Выравнивание границ модулей по границам осей означает, что изменение по любой оси трогает ровно одно место и не ставит под угрозу ничего больше — а это и есть связность (то, что меняется вместе, живёт вместе), увиденная со стороны группировки. Senior-тест: «если требование R меняется, сколько модулей мне придётся тронуть и рискуют ли сломаться непричастные?» Но нож режет в обе стороны: разрезание вдоль вымышленных осей, которые никогда не варьируются независимо, заново создаёт shotgun surgery как косвенность без купленной гибкости. Разделяй оси, которые доказуемо двигаются врозь; держи вместе те, что двигаются как одно.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.