Тиск: фінанси виправили округлення, а 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) або підмінити реалізацію в тесті.
Опис
Схема слева направо. Слева три овала-актора: «Финансы», «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).
- Ціна — більше типів, координатор і необхідність знати, хто чим володіє.
