open atlas
↑ К треку
Паттерны и качество кода CP · 04 · 02

Акторы и оси изменения

Компонент часто смешивает три независимые оси изменения — представление, бизнес-правило, хранение, — каждую запрашивает свой актор. Группируй код по оси, чтобы одно изменение трогало одно место. SRP бьёт по связанности осей; резать вымышленные оси — это лишняя косвенность.

CP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

«Одна ответственность» звучит как упражнение на подсчёт — пусть каждый класс делает что-то одно, — и на практике эта формулировка бесполезна, потому что любой полезный класс делает несколько вещей. Класс Report загружает данные, применяет правило и форматирует вывод: это три вещи, и всё же разбиение по «вещам» не даёт принципиального места, где остановиться. Считай глаголы — и можно оправдать любое число классов от одного до дюжины.

Версия, которая реально направляет проектирование, острее: у модуля должна быть одна причина меняться — один источник запросов на изменение. Способ найти эти причины — спросить, для каждой строки кода, кто просит её изменить и вдоль какой оси. Строки, отвечающие одному человеку и меняющиеся по одной причине, принадлежат вместе. Строки, отвечающие разным людям и меняющиеся независимо, — это связанность, которая ждёт момента укусить.

Цель

После этого урока ты можешь выявить независимые оси изменения в запутанном компоненте, спрашивая «кто запрашивает изменение вдоль этой оси?»; сгруппировать код так, чтобы изменение по одной оси трогало ровно один модуль; объяснить, почему это убивает shotgun surgery и почему связность («то, что меняется вместе, живёт вместе») — та же идея с другой стороны; и распознать режим отказа — разрезание вдоль осей, которые на деле никогда не варьируются независимо, что покупает косвенность и нулевую гибкость.

1

«Ось изменения» — это направление, вдоль которого требования двигаются независимо, и у каждой оси есть владелец, который её толкает. Забудь про подсчёт методов. Спроси вместо этого: какие виды запросов на изменение приходят на этот код и приходят ли они по отдельности? Сервис ценообразования обычно сталкивается как минимум с тремя: маркетинг меняет, как цены отображаются (валюта, локаль, округление); финансы меняют, как вычисляется налог или скидка (бизнес-правило); платформа/ops меняют, как результаты хранятся (схема, хранилище, колонки аудита). Эти три запроса приходят от трёх разных людей по трём разным графикам. Каждый — это ось. Владелец оси — «актор» в формулировке SRP у Дядюшки Боба — это тот, к кому ты бы пошёл спросить, когда такое изменение понадобится.

Смысл называть актора в том, что это делает границу объективной. «Является ли форматирование отдельной ответственностью от налоговой математики?» — вопрос вкуса. «Отличается ли человек, который меняет форматирование, от человека, который меняет налоговое правило?» — на это есть ответ, и в большинстве команд ответ «да».

2

Когда две оси делят один модуль, изменение по одной оси рискует сломать другую — это связанность между осями, и именно эту цену бьёт 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 и $-форматированием — ревьюер от финансов вынужден читать код, которым не владеет, а небрежная правка правила может сдвинуть округление, записываемое в базу. Три заботы не просто сосуществуют; они делят состояние и порядок выполнения, поэтому каждая открыта ошибкам остальных.

3

Разделение по оси означает, что изменение по одной оси трогает ровно один модуль — это лекарство от 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. Ни одна из них не может сломать другие, потому что они больше не делят тело.

4

Связность — тот же принцип с другой стороны: то, что меняется вместе, живёт вместе; то, что меняется врозь, живёт врозь. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.