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

Одна причина для изменения

SRP — это не «делай одно». У модуля должна быть одна причина для изменения: он отвечает перед одним актором, чьи меняющиеся требования вынуждают правки. Слияние акторов (Финансы + HR + DBA) в один класс — это нарушение; разделяй по актору.

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

Финансы просят изменить, как считается оплата сверхурочных. Ты правишь Employee.calculatePay(), выкатываешь это, а через неделю HR заводит баг: отчёт по часам на их дашборде теперь слегка неверен. Отчёта ты не трогал. Но два метода делили общий хелпер, и твоя правка «оплаты» протекла в «часы». Два отдела, один класс, одна авария, ждущая своего часа.

Именно этот провал и призван предотвратить принцип единственной ответственности — и почти все сначала учат SRP как неправильное правило. «Класс должен делать одно дело» звучит верно и почти бесполезно: оно достаточно расплывчато, чтобы оправдать любое разделение и любое слияние. Настоящий принцип острее, и он про то, кто может заставить тебя изменить код.

Цель

После этого урока ты можешь сформулировать SRP в точной формулировке дяди Боба — у модуля должна быть ровно одна причина для изменения, где «причина» — это актор, чьи требования диктуют правки; определить акторов, стоящих за методами класса, и распознать, когда несколько акторов связаны в один модуль; разделить класс по актору, а не по глаголу; и избежать обратного провала — измельчения класса на однометодные фрагменты из-за неверного прочтения SRP как «один публичный метод на класс».

1

SRP — про одну причину для изменения, а не про одно сделанное дело. Собственная поправка Роберта Мартина пряма: принцип никогда не был «модуль должен делать одно дело». Эта народная версия — то, что помнит большинство, и именно поэтому про SRP спорят бесконечно — у вопроса «считаются ли парсинг и валидация за одно дело или за два?» нет ответа. Реальная формулировка: у модуля должна быть одна, и только одна, причина для изменения. «Причина для изменения» — это не фича и не метод; это источник требований — человек или роль, которые могут прийти и потребовать, чтобы поведение было другим.

// "Does this do one thing?" — unanswerable, and the wrong question.
// "Who can ask me to change this?" — answerable, and the right one.

Сдвиг от подсчёта глаголов к определению заказчиков — это весь урок целиком.

2

«Причина для изменения» — это актор. Мартин заостряет «причину для изменения» в актора: группу пользователей или стейкхолдеров, ради которых делается данное изменение. Финансы определяют, как считается оплата. HR определяет, что считается отчётными часами. Команда DBA определяет схему хранения. Каждый — отдельный актор с отдельными требованиями, которые дрейфуют по собственному расписанию. Каноническое нарушение — один класс, обслуживающий всех трёх:

class Employee {
  calculatePay(): Money { /* Finance owns this rule */ }
  reportHours(): Hours { /* HR owns this rule */ }
  save(): void      { /* DBA owns this schema */ }
}

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

3

Опасность не в уродстве — она в случайной связанности между акторами. Причина, по которой это принцип, а не стилистическая заметка, — тот провал, который он предотвращает. Когда два актора делят класс, они склонны делить и приватные хелперы, и изменение, запрошенное одним актором, молча меняет поведение, на которое полагается другой актор.

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, потому что они были сварены вместе. Связанность невидима, пока не выкатит неверный отчёт.

4

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

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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.