Давление: финансы поправили округление, а 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) или подменить реализацию в тесте.
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 связан с тестами?» Класс с пятью ответственностями требует пять заглушек в каждом тесте, хотя конкретному тесту нужна одна. Если настройка теста длиннее проверки, это сигнал о дизайне, а не о тесте.
Что забрать из юнита
- SRP — одна причина для изменения, то есть один актор, а не «одна вещь».
- Рабочий вопрос: от кого придёт письмо «поменяйте X» и какие файлы оно тронет.
- Самый опасный признак — общий приватный код у методов разных акторов.
- Данные могут быть общими; обязанности — нет.
- Похожий код у разных акторов — не нарушение DRY. Объединять его — значит связывать отделы.
- Обратное правило: что меняется вместе, живёт вместе. Перебор так же вреден, как недобор.
- SRP работает на уровне методов (флаг-переключатель хозяина), модулей (папки по акторам) и правил вроде порядка сортировки (u05).
- Цена — больше типов, координатор и необходимость знать, кто чем владеет.
