The pressure: finance changed the rounding, and HR's overtime was recalculated
ENGAGE has a class that the week close goes through. It calculates the amount to bill the client, the employee's overtime, and a line of the file that goes to the client:
// 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;
}
Before the change, Hours simply returned e.Hours. The finance department asked to bill time in 15-minute blocks, and the developer changed one place — the shared helper. The billing tests went green. An employee's week is five entries of 8.1 hours each:
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
Finance got what it wanted. HR got overtime 0.75 hours higher than the actual figure, and payroll will pay for it. The client got a file in which the hours don't match its own time-tracking system, while the contract requires actual time. One change at the request of one department broke two others. Nobody violated "a class does one thing": each method does exactly one thing.
A reason to change is a person, not a function
"A class should do one thing" is not SRP. By that formulation TimesheetService is flawless: it "works with timesheets".
Robert Martin states the principle like this: a module has one reason to change. In a later edition, even more precisely: a module is responsible to one actor. An actor is a group of people who come with requirements: a department, a role, an external customer, a regulator.
Hence the working question. Not "does the class do one thing", but:
If an email arrives tomorrow saying "change X", who will it be from, and which files will have to be touched?
For TimesheetService there are three answers:
| Actor | What it owns | When it comes |
|---|---|---|
| Finance | rounding, rates, the amount payable | a new contract, an audit |
| HR | the hours norm, overtime rules | labor law, company policy |
| Client | the format and contents of the export file | contract terms, their time-tracking system |
Three owners — three reasons to change. The worst part is not that they live in one file but that they share the private helper Hours. Shared code between different actors is a channel through which one actor's change leaks to another. A merge conflict when two teams edit one file is a mild symptom. A neighbor's numbers quietly changing is a severe one.
Splitting by actors
We split not by technical layers but by who comes with the change:
// Финансы
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}");
}
On the same week: BillableAmount is 7425.00, OvertimeHours is 0.5, and the export line is 2026-10-12;ENG-042;8.1. The rounding now belongs to finance and is invisible to everyone else.
Three things in this split matter more than the classes themselves.
- The data stayed shared. There is one
TimesheetEntryfor everyone. SRP is about responsibilities, not about data: a timesheet entry is a fact that all three departments read. - "Duplication" appeared.
e.Hoursoccurs in three classes, where there used to be one helper. This is not a DRY violation. DRY is about knowledge, not about identical lines. Today the rules coincide by accident, and they change for different reasons. Merging them back would mean laying the channel between finance and HR again. - The week-close scenario didn't go anywhere. It is driven by a coordinator, which has its own reason to change: the order of the steps.
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);
Interfaces are not required here. SRP is about the boundaries of responsibility, not about an I in front of a name. An interface will be needed when there is a reason to invert the dependency (u21) or to substitute the implementation in a test.
Description
Схема слева направо. Слева три овала-актора: «Финансы», «HR», «Клиент». До разреза все три стрелки ведут в один красноватый узел «TimesheetService (до разреза)» с подписями «округление», «норма часов», «формат файла»; из него пунктирный узел «private Hours() общий помощник» со стрелкой «все три метода» — это канал, по которому правка финансов доходит до HR и клиента. После разреза от каждого актора идёт своя стрелка «после»: Финансы — к «BillingCalculator», HR — к «OvertimeCalculator», Клиент — к «TimesheetCsvExporter». Отдельный узел «WeekCloseJob, порядок шагов» вызывает все три класса. Смысл схемы: у каждого класса после разреза ровно один входящий актор, общий помощник исчез, а сценарий целиком держит координатор.
The reverse rule: what changes together lives together
SRP is symmetric, and the second half gets forgotten. If two pieces of code always change together at the request of one actor, they have one reason to change, and they must not be split. This is cohesion. At the package level Martin calls the same rule the Common Closure Principle: classes that change for the same reason are gathered into one module.
BillingCalculator contains the rounding, the rates and the total. Finance comes with changes to all three, often in a single email: "from the new year, rounding to 6 minutes and a new rate for ENG-042". One file, one review, one test. If you spread them across different classes for the sake of "one thing", every email from finance will touch three files.
The sign of a correct split is checked by history, not by code: files that always end up in the same commit are most likely one responsibility. A file that ends up in commits of different teams is most likely two.
Overdoing it: a class for every line
SRP taken to literalism gives this picture:
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
Finance asks: "for on-site work, a minimum of one hour per visit". The change touches the rounding, the multiplication and the coordinator — three files, and there are more tests than code. Each class does one thing, but all four have one owner, and the split ran across that owner's responsibility.
Signs of overdoing it:
- to understand one scenario, you have to open eight files;
- one requirement from one actor always edits several files at once;
- names like
…Helper,…Processor,…Managerthat don't tell you whose code it is; - an interface with a single implementation that nobody substitutes;
- a coordinator that only passes arguments along.
The cure is to merge back what changes together. This is not a rollback to a "god class": BillingCalculator contains neither HR nor the client.
SRP at the level of methods and modules
The principle works at any level where there is a boundary and someone who edits it.
A method. The most common case is a flag that chooses the owner:
// два актора в одном методе: флаг решает, чей формат
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}"));
The client needs a file per the contract; internal accounting needs a full one. Each branch changes for its own reason, and any change to one requires rereading and retesting the other. Two methods, ExportForClient and ExportForAccounting, are more honest. The same sign is a method that calculates, saves and sends: the result is convenient to call, but impossible to reuse in part.
A module. In ENGAGE, folders and projects by actor (Billing/, Payroll/, ClientReporting/) survive changes better than folders by technical layer (Services/, Helpers/, Repositories/). In the second case a finance requirement is smeared across three folders, and none of them holds the whole picture. The internal details of each module are closed off with internal (u08): HR physically cannot call finance's rounding.
A data type. Its own sort order, its own output format, its own equality for a specific task are also responsibilities with their own owner. That is what the next section is about.
The link to u05: order belongs to the task
In u05, TimesheetEntry doesn't implement IComparable<T>. The client report needs ordering by date, the billing admin panel by amount, support by client priority. If you bake the order into the type, the very first request from support to "sort differently" will break the client report. That is why the order lives outside, in an IComparer<TimesheetEntry>, next to whoever needs it:
// ClientReporting/ — порядок, о котором договорились с клиентом
var byDate = Comparer<TimesheetEntry>.Create((a, b) => a.Date.CompareTo(b.Date));
This is SRP in miniature: a rule is put where its actor lives. The same argument applies to separate IEqualityComparer<T> instances for deduplication and for reconciliation with the HR API. And the same thing will become a Strategy in u24.
How to recognize a violation in code
- Constructor dependencies from different worlds.
IHrApiClient,IRateRepositoryandICsvWriterin one class are three departments. - A private helper called by methods of different actors. The most dangerous sign: a change in it breaks a neighbor without a single change to the neighbor's methods.
- A test requires five stubs when one is needed. The number of dependencies in a test mirrors the number of responsibilities. Tests here are not the goal but an instrument.
- Merge conflicts in one file between different teams.
- A name with "And" or a faceless
…Service. The name itself proves nothing, but it asks: "whose code is this?"
None of these signs proves a violation. Only the answer to the question "who will come with the change" proves it.
The cost
- More types and files. The week-close scenario now reads across four classes instead of one.
- The coordinator is new code with its own possible bugs: you can forget a step or mix up the order.
- Deliberate duplication. Someone will want to "extract into a shared helper" the similar lines in
BillingCalculatorandOvertimeCalculator. The decision has to be recorded in a comment or in a review, otherwise the channel between departments will come back. - You need to know the organization. Splitting by actors requires understanding who in the company owns which rule. Without that, SRP degenerates into "I'll split by methods" — the very overdoing described above.
- A split along the wrong line is worse than no split. One big class can at least be seen as a whole.
How it sounds in an interview
"What is SRP?" A strong answer: a module has one reason to change, that is, one actor who comes with requirements. Then an example: a class computes the amount payable and the overtime through a shared helper; finance changes the rounding, and HR's numbers quietly change. Then the boundary: the rule is symmetric, what changes together lives together. The answer "a class does one thing" is weak: it doesn't say where to draw the boundary. Follow-up question: "And how will you know you've split too finely?" — one requirement from one actor always edits several files.
"Two methods in different classes contain identical code. Should it be extracted into a shared helper?" It depends on whose they are. If both are owned by one actor and change together — extract it. If by different actors, the coincidence is accidental, and the shared helper will become a channel for other people's changes. DRY is about knowledge, not about text. Follow-up question: "And if the coincidence is real, for example a formula from the law?" — then it is a separate piece of knowledge with its own owner, and it needs its own module.
"How is SRP related to tests?" A class with five responsibilities requires five stubs in every test, although a particular test needs one. If the test setup is longer than the assertion, it is a signal about the design, not about the test.
What to take away from the unit
- SRP is one reason to change, that is, one actor — not "one thing".
- The working question: who will the "change X" email come from, and which files will it touch.
- The most dangerous sign is shared private code between methods of different actors.
- Data can be shared; responsibilities can't.
- Similar code belonging to different actors is not a DRY violation. Merging it means coupling departments.
- The reverse rule: what changes together lives together. Overdoing it is as harmful as underdoing it.
- SRP works at the level of methods (a flag that switches the owner), modules (folders by actor) and rules such as sort order (u05).
- The cost is more types, a coordinator and the need to know who owns what.
