Skip to content
L-SYSTEM

SRP: одна причина для изменения

Preview unit: this one is open without enrolling. The rest open after you enrol.

Давление: финансы поправили округление, а HR пересчитал переработки

В ENGAGE есть класс, через который проходит закрытие недели. Он считает сумму к выставлению клиенту, переработки сотрудника и строку файла, который уходит клиенту:

// ENGAGE, Timesheets/TimesheetService.cs — код с дефектом, это учебный материал
public sealed class TimesheetService(IReadOnlyDictionary<string, decimal> ratePerHour)
{
    // Финансы: сколько выставить клиенту
    public decimal BillableAmount(IEnumerable<TimesheetEntry> week) =>
        week.Sum(e => Hours(e) * ratePerHour[e.EngagementCode]);

    // HR: сколько часов сверх 40 в неделю
    public decimal OvertimeHours(IEnumerable<TimesheetEntry> week) =>
        Math.Max(0m, week.Sum(Hours) - 40m);

    // Клиент: строка файла выгрузки по договору
    public string ExportLine(TimesheetEntry e) =>
        string.Create(CultureInfo.InvariantCulture,
            $"{e.Date:yyyy-MM-dd};{e.EngagementCode};{Hours(e)}");

    // Общий помощник. Финансы попросили: «округляйте вверх до четверти часа».
    private static decimal Hours(TimesheetEntry e) => Math.Ceiling(e.Hours * 4) / 4;
}

До правки Hours просто возвращал e.Hours. Финансовый отдел попросил выставлять время блоками по 15 минут, и разработчик поправил одно место — общий помощник. Тесты биллинга позеленели. Неделя сотрудника — пять записей по 8,1 часа:

Console.WriteLine(svc.BillableAmount(week));   // 7425.00 — как и просили финансы
Console.WriteLine(svc.OvertimeHours(week));    // 1.25   — было 0.5
Console.WriteLine(svc.ExportLine(week[0]));    // 2026-10-12;ENG-042;8.25 — было 8.1

Финансы получили то, что хотели. HR получил переработки на 0,75 часа больше фактических, и payroll заплатит за них. Клиент получил файл, где часы не совпадают с его собственной системой учёта, а договор требует фактическое время. Одна правка по просьбе одного отдела сломала два других. Никто не нарушил «класс делает одну вещь»: каждый метод делает ровно одну вещь.

Причина изменения — это человек, а не функция

«Класс должен делать одну вещь» — не SRP. По этой формулировке TimesheetService безупречен: он «работает с таймшитами».

Роберт Мартин формулирует принцип так: у модуля одна причина для изменения. В поздней редакции ещё точнее: модуль отвечает перед одним актором. Актор — группа людей, которая приходит с требованиями: отдел, роль, внешний заказчик, регулятор.

Отсюда рабочий вопрос. Не «делает ли класс одну вещь», а:

Если завтра придёт письмо «поменяйте X», от кого оно будет и какие файлы придётся тронуть?

Для TimesheetService ответов три:

Актор Чем владеет Когда приходит
Финансы округление, ставки, сумма к оплате новый договор, аудит
HR норма часов, правила переработок трудовое право, политика компании
Клиент формат и состав файла выгрузки условия договора, их система учёта

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

Разрез по акторам

Делим не по техническим слоям, а по тому, кто приходит с правкой:

// Финансы
public sealed class BillingCalculator(IReadOnlyDictionary<string, decimal> ratePerHour)
{
    public decimal BillableAmount(IEnumerable<TimesheetEntry> entries) =>
        entries.Sum(e => BillableHours(e) * ratePerHour[e.EngagementCode]);

    // правило финансов: вверх до четверти часа
    private static decimal BillableHours(TimesheetEntry e) => Math.Ceiling(e.Hours * 4) / 4;
}

// HR
public sealed class OvertimeCalculator(decimal weeklyNorm = 40m)
{
    public decimal OvertimeHours(IEnumerable<TimesheetEntry> week) =>
        Math.Max(0m, week.Sum(e => e.Hours) - weeklyNorm);
}

// Договор с клиентом
public sealed class TimesheetCsvExporter
{
    public string Line(TimesheetEntry e) =>
        string.Create(CultureInfo.InvariantCulture, $"{e.Date:yyyy-MM-dd};{e.EngagementCode};{e.Hours}");
}

На той же неделе: BillableAmount — 7425.00, OvertimeHours — 0.5, строка выгрузки — 2026-10-12;ENG-042;8.1. Округление теперь принадлежит финансам и не видно никому больше.

Три вещи в этом разрезе важнее самих классов.

  • Данные остались общими. TimesheetEntry один на всех. SRP говорит об обязанностях, а не о данных: запись таймшита — факт, который читают все три отдела.
  • Появилось «дублирование». В трёх классах встречается e.Hours, а раньше был один помощник. Это не нарушение DRY. DRY — про знание, а не про одинаковые строки. Сегодня правила совпадают случайно, а меняются по разным причинам. Объединить их обратно — значит снова проложить канал между финансами и HR.
  • Сценарий закрытия недели никуда не делся. Его ведёт координатор, у которого своя причина для изменения: порядок шагов.
public sealed class WeekCloseJob(BillingCalculator billing, OvertimeCalculator overtime, TimesheetCsvExporter export)
{
    public WeekCloseResult Close(IReadOnlyList<TimesheetEntry> week) =>
        new(billing.BillableAmount(week), overtime.OvertimeHours(week), [.. week.Select(export.Line)]);
}

public sealed record WeekCloseResult(decimal Billable, decimal Overtime, IReadOnlyList<string> ExportLines);

Интерфейсы здесь не обязательны. SRP — про границы ответственности, а не про I перед именем. Интерфейс понадобится, когда появится причина развернуть зависимость (u21) или подменить реализацию в тесте.

fin Финансы ts TimesheetService (до разреза) fin->ts округление bc BillingCalculator fin->bc после hr HR hr->ts норма часов oc OvertimeCalculator hr->oc после cl Клиент cl->ts формат файла ex TimesheetCsvExporter cl->ex после hlp private Hours() общий помощник ts->hlp все три метода job WeekCloseJob порядок шагов job->bc job->oc job->ex
FigureТри актора до и после разреза: один класс с общим помощником против трёх классов, у каждого свой хозяин
Description

Схема слева направо. Слева три овала-актора: «Финансы», «HR», «Клиент». До разреза все три стрелки ведут в один красноватый узел «TimesheetService (до разреза)» с подписями «округление», «норма часов», «формат файла»; из него пунктирный узел «private Hours() общий помощник» со стрелкой «все три метода» — это канал, по которому правка финансов доходит до HR и клиента. После разреза от каждого актора идёт своя стрелка «после»: Финансы — к «BillingCalculator», HR — к «OvertimeCalculator», Клиент — к «TimesheetCsvExporter». Отдельный узел «WeekCloseJob, порядок шагов» вызывает все три класса. Смысл схемы: у каждого класса после разреза ровно один входящий актор, общий помощник исчез, а сценарий целиком держит координатор.

Обратное правило: что меняется вместе, живёт вместе

SRP симметричен, и вторую половину забывают. Если два куска кода всегда меняются вместе по просьбе одного актора, у них одна причина для изменения, и разделять их нельзя. Это связность (cohesion). На уровне пакетов Мартин называет то же правило Common Closure Principle: классы, которые меняются по одной причине, собирают в один модуль.

В BillingCalculator живут и округление, и ставки, и сумма. Финансы приходят с правками ко всем трём, часто в одном письме: «с нового года округление до 6 минут и новая ставка для ENG-042». Один файл, одна проверка, один тест. Если разнести их по разным классам ради «одной вещи», каждое письмо финансов будет трогать три файла.

Признак правильного разреза проверяется по истории, а не по коду: файлы, которые всегда попадают в один коммит, скорее всего, одна ответственность. Файл, который попадает в коммиты разных команд, скорее всего, две.

Перебор: класс на каждую строку

SRP, доведённый до буквализма, даёт такую картину:

public interface IBillableHoursRounder   { decimal Round(decimal hours); }
public interface IRateResolver           { decimal RateFor(string engagementCode); }
public interface IBillableLineMultiplier { decimal Multiply(decimal hours, decimal rate); }
public interface IBillableTotalAggregator{ decimal Sum(IEnumerable<decimal> lines); }
// …и четыре реализации, четыре регистрации в DI, координатор BillingOrchestrator

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

Признаки перебора:

  • чтобы понять один сценарий, приходится открыть восемь файлов;
  • одно требование одного актора всегда правит несколько файлов сразу;
  • имена …Helper, …Processor, …Manager, по которым не скажешь, чей это код;
  • интерфейс с единственной реализацией, которую никто не подменяет;
  • координатор, который только передаёт аргументы дальше.

Лечение — слить обратно то, что меняется вместе. Это не откат к «божественному классу»: в BillingCalculator нет ни HR, ни клиента.

SRP на уровне методов и модулей

Принцип работает на любом уровне, где есть граница и кто-то, кто её правит.

Метод. Самый частый случай — флаг, выбирающий хозяина:

// два актора в одном методе: флаг решает, чей формат
public string Export(IReadOnlyList<TimesheetEntry> entries, bool forClient) =>
    forClient
        ? string.Join('\n', entries.Select(e => $"{e.Date:yyyy-MM-dd};{e.Hours}"))
        : string.Join('\n', entries.Select(e => $"{e.EmployeeId};{e.Date:yyyy-MM-dd};{e.Hours};{e.EngagementCode}"));

Клиенту нужен файл по договору, внутренней бухгалтерии — полный. Каждая ветка меняется по своей причине, и любая правка одной требует перечитать и перетестировать другую. Два метода, ExportForClient и ExportForAccounting, честнее. Тот же признак — метод, который и считает, и сохраняет, и отправляет: результат удобно вызывать, но невозможно переиспользовать часть.

Модуль. В ENGAGE папки и проекты по акторам (Billing/, Payroll/, ClientReporting/) переживают изменения лучше, чем папки по техническим слоям (Services/, Helpers/, Repositories/). Во втором случае требование финансов размазано по трём папкам, и ни в одной нет целой картины. Внутренние детали каждого модуля закрываются internal (u08): HR физически не может вызвать округление финансов.

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

Связь с u05: порядок принадлежит задаче

В u05 TimesheetEntry не реализует IComparable<T>. Отчёту клиенту нужен порядок по дате, админке биллинга — по сумме, поддержке — по приоритету клиента. Если зашить порядок в тип, первая же просьба поддержки «сортируйте иначе» сломает отчёт клиенту. Поэтому порядок живёт снаружи, в IComparer<TimesheetEntry>, рядом с тем, кому он нужен:

// ClientReporting/ — порядок, о котором договорились с клиентом
var byDate = Comparer<TimesheetEntry>.Create((a, b) => a.Date.CompareTo(b.Date));

Это SRP в миниатюре: правило кладут туда, где живёт его актор. Тот же довод — для отдельных IEqualityComparer<T> под дедупликацию и под сверку с HR API. И то же самое станет Стратегией в u24.

Как распознать нарушение в коде

  • Зависимости конструктора из разных миров. IHrApiClient, IRateRepository и ICsvWriter в одном классе — три отдела.
  • Приватный помощник, который вызывают методы разных акторов. Самый опасный признак: правка в нём ломает соседа без единой правки в его методах.
  • Тест требует пять заглушек, а нужна одна. Число зависимостей в тесте повторяет число ответственностей. Тесты здесь не цель, а прибор.
  • Конфликты слияния в одном файле у разных команд.
  • Имя с «And» или безликое …Service. Само имя ничего не доказывает, но спрашивает: «чей это код?»

Ни один признак не доказывает нарушение. Доказывает только ответ на вопрос «кто придёт с правкой».

Цена

  • Больше типов и файлов. Сценарий закрытия недели теперь читается по четырём классам, а не по одному.
  • Координатор — новый код со своей ошибкой: можно забыть шаг или перепутать порядок.
  • Осознанное дублирование. Похожие строки в BillingCalculator и OvertimeCalculator кто-нибудь захочет «вынести в общий помощник». Решение надо зафиксировать комментарием или в ревью, иначе канал между отделами вернётся.
  • Нужно знать организацию. Разрез по акторам требует понимать, кто в компании владеет каким правилом. Без этого SRP вырождается в «разрежу по методам» — тот самый перебор.
  • Разрез не по той линии хуже отсутствия разреза. Один большой класс хотя бы виден целиком.

Как это звучит на интервью

«Что такое SRP?» Сильный ответ: у модуля одна причина для изменения, то есть один актор, который приходит с требованиями. Дальше пример: класс считает сумму к оплате и переработки через общий помощник; финансы меняют округление, и у HR тихо меняются цифры. Затем граница: правило симметрично, что меняется вместе — живёт вместе. Ответ «класс делает одну вещь» — слабый: он не отвечает, где провести границу. Встречный вопрос: «А как вы поймёте, что разрезали слишком мелко?» — одно требование одного актора всегда правит несколько файлов.

«Два метода в разных классах содержат одинаковый код. Вынести в общий помощник?» Смотря чьи они. Если обоими владеет один актор и меняются они вместе — вынести. Если разные, совпадение случайное, и общий помощник станет каналом для чужих правок. DRY — про знание, а не про текст. Встречный вопрос: «А если совпадение настоящее, например формула из закона?» — тогда это отдельное знание со своим владельцем, и ему нужен свой модуль.

«Как SRP связан с тестами?» Класс с пятью ответственностями требует пять заглушек в каждом тесте, хотя конкретному тесту нужна одна. Если настройка теста длиннее проверки, это сигнал о дизайне, а не о тесте.

Что забрать из юнита

  1. SRP — одна причина для изменения, то есть один актор, а не «одна вещь».
  2. Рабочий вопрос: от кого придёт письмо «поменяйте X» и какие файлы оно тронет.
  3. Самый опасный признак — общий приватный код у методов разных акторов.
  4. Данные могут быть общими; обязанности — нет.
  5. Похожий код у разных акторов — не нарушение DRY. Объединять его — значит связывать отделы.
  6. Обратное правило: что меняется вместе, живёт вместе. Перебор так же вреден, как недобор.
  7. SRP работает на уровне методов (флаг-переключатель хозяина), модулей (папки по акторам) и правил вроде порядка сортировки (u05).
  8. Цена — больше типов, координатор и необходимость знать, кто чем владеет.

Practice

Checking here changes nothing: answers start counting once you enrol on the course.

  1. Какая формулировка SRP точнее всего?

  2. Финансы попросили округлять время вверх до четверти часа, и разработчик поправил общий помощник. Впишите число, которое выведет код (культура инвариантная). ```csharp public sealed class TimesheetService { public decimal OvertimeHours(IEnumerable<TimesheetEntry> week) => Math.Max(0m, week.Sum(Hours) - 40m); private static decimal Hours(TimesheetEntry e) => Math.Ceiling(e.Hours * 4) / 4; // …BillableAmount и ExportLine тоже вызывают Hours } // пять записей одной недели, у каждой Hours = 8.1m Console.WriteLine(new TimesheetService().OvertimeHours(week)); ``` Ответ:

  3. Разложите правила ENGAGE по акторам: кто придёт с требованием их поменять.

    1. Округление выставляемого времени вверх до 15 минут
    2. Порог 40 часов в неделю для переработок
    3. Порядок колонок в CSV-файле выгрузки
    4. Ставка часа по engagement ENG-042
    5. Коэффициент оплаты сотруднику за работу в выходной
    6. Разделитель «;» и формат даты в файле выгрузки
  4. Отметьте каждое утверждение: верно или неверно.

    1. Класс, в котором каждый метод делает одну вещь, может нарушать SRP
    2. Одинаковый код в классах разных акторов надо вынести в общий помощник
    3. Если два файла всегда меняются в одном коммите по просьбе одного отдела, их стоит слить
    4. SRP требует завести интерфейс для каждого класса
    5. Общий тип данных TimesheetEntry, который читают и финансы, и HR, нарушает SRP
    6. Класс на 20 строк всегда соблюдает SRP
  5. Что делать в каждой ситуации: разделить код или оставить (слить) вместе?

    1. Приватный помощник вызывают и расчёт суммы к оплате, и расчёт переработок
    2. Округление и ставки финансы меняют одним письмом
    3. Метод Export(entries, bool forClient) с ветками для клиента и для бухгалтерии
    4. Четыре интерфейса биллинга, которые всегда правят вместе по просьбе финансов
    5. Класс зависит от IHrApiClient и от ICsvWriter для файла клиенту
    6. Проверка и расчёт суммы счёта меняются по одним и тем же пунктам договора
  6. После разреза в BillingCalculator и OvertimeCalculator часы берутся одинаково — e.Hours. Ревьюер просит: «вынесите в общий статический TimesheetMath.Hours, это дублирование». Какой ответ верен?

  7. Что не так в этом методе с точки зрения SRP? ```csharp public string Export(IReadOnlyList<TimesheetEntry> entries, bool forClient) => forClient ? string.Join('\n', entries.Select(e => $"{e.Date:yyyy-MM-dd};{e.Hours}")) : string.Join('\n', entries.Select(e => $"{e.EmployeeId};{e.Date:yyyy-MM-dd};{e.Hours};{e.EngagementCode}")); ```

  8. Назовите всех акторов, чьё требование заставит править этот класс ENGAGE, и объясните, чем опасно, что они живут вместе: ```csharp public sealed class EngagementReport { public decimal BudgetBurn(Engagement e) { /* часы × ставки, остаток бюджета */ } public string RenderPdf(Engagement e) { /* вёрстка отчёта клиенту */ } public void Save(Engagement e) { /* SQL */ } public decimal UtilizationOf(string employeeId) { /* загрузка сотрудника для HR */ } } ```

    This one is judged by the author’s rubric — your assistant does that, not the site.

  9. Разделите класс EngagementReport из предыдущего задания. Покажите новые типы, их сигнатуры и кто кого вызывает. Обязательно назовите, что стало хуже.

    This one is judged by the author’s rubric — your assistant does that, not the site.

  10. Команда разбила выставление счёта в ENGAGE на 12 классов по одному методу, у каждого свой интерфейс. Каждое новое требование финансов правит восемь из них. Что это говорит и что делать?

    This one is judged by the author’s rubric — your assistant does that, not the site.

  11. Почему «настройка теста длиннее самого теста» — признак нарушенного SRP, а не плохого теста?

    This one is judged by the author’s rubric — your assistant does that, not the site.