Splitting without shattering
SRP has a sweet spot. Over-applied, it shatters one cohesive concept into a fog of tiny classes you must reassemble in your head — a god class in reverse. Split on real change boundaries (independent cadence, different actors); keep together what changes together.
You learned to break up a god class — the 2,000-line OrderService that did pricing, persistence, email, and PDF generation. So you split. And split. And split. Now placeOrder touches OrderValidator, OrderPriceCalculator, OrderTaxApplier, OrderTotalSummariser, OrderRepository, OrderEventEmitter, and an OrderMapper — eight files to follow one request, each a thin wrapper that does one statement and delegates. Nobody can read it. To understand a single order you reassemble the whole thing in your head, file by file.
You traded one failure for its mirror image. A god class is too little splitting; this fog of fragments is too much. Both are SRP failures — and the cure for one is not “more of the cure.” SRP optimises cohesion, and cohesion has a sweet spot. This lesson is about finding it.
After this lesson you can state SRP as “one reason to change” rather than “one method,” and use that to size a split correctly; decide when to split using two concrete tests — independent change cadence and different actors — and when to keep together using shared state and joint change; recognise class-explosion (the over-applied SRP smell) as distinct from a god class; and defend a split by pointing to a real, observed change boundary instead of a dogmatic rule.
SRP is about reasons to change, not about size. The principle is often misquoted as “a class should do one thing,” which invites you to count statements and split until every class has one method. The actual formulation is sharper: a module should have one reason to change — or, equivalently, it should be responsible to one actor. An “actor” is a source of change: a stakeholder, a policy, a protocol that evolves on its own clock.
// Robert Martin's classic example: three actors hiding in one class.
class Employee {
calculatePay() { /* changes when the CFO / payroll policy changes */ }
reportHours() { /* changes when the COO / ops reporting changes */ }
save() { /* changes when the DBA / schema changes */ }
}These three methods serve three different actors who change for unrelated reasons. That — not the line count — is why Employee should split. The right number of pieces is the number of independent reasons to change, no more and no fewer. Size is a symptom you investigate; it is not the rule itself.
Split when two responsibilities have genuinely independent change cadence. The most reliable signal is observed, not imagined: do these two things change at different times, for different requests? If pricing rules change quarterly with the finance team and the email template changes whenever marketing has a campaign, they are on different clocks. Keeping them in one class means every marketing tweak risks the pricing logic, and every pricing review drags through email markup.
// Different clocks → split. PricingPolicy changes on the finance cadence;
// ReceiptTemplate changes on the marketing cadence. They never move together.
class PricingPolicy { totalFor(cart: Cart): Money { /* finance owns this */ } }
class ReceiptTemplate { render(order: Order): Html { /* marketing owns this */ } }The test is empirical: look at the git history. If two regions of a file appear in commits for different reasons, that is a real seam. Splitting along it means future changes stay local — a finance change touches PricingPolicy only. Splitting across it (a clock you invented) buys nothing and costs indirection.
Keep together what always changes together and shares state. The inverse test is just as important and far more often ignored. Two pieces of logic that always appear in the same commit, that share the same invariants, that read and write the same fields — these are one responsibility wearing two method names. Splitting them scatters a single concept across files and forces every change to hop between them.
// Cohesive: validate and apply both enforce the SAME invariant
// (a balance never goes negative) over the SAME field. They always
// change together. Splitting them into Validator + Applier classes
// would mean the invariant lives in two files that must stay in sync.
class Account {
#balance: Money;
withdraw(amount: Money): void {
if (amount.gt(this.#balance)) throw new InsufficientFunds();
this.#balance = this.#balance.minus(amount); // same field, same rule
}
}When you split this, a change to the overdraft rule now means editing AccountValidator and AccountApplier and keeping them consistent — you manufactured the very shotgun-surgery you were trying to avoid. Connascent logic (things that must change together to stay correct) belongs together. High cohesion is the goal; SRP is just one lever on it.
Class explosion is an SRP failure, not an SRP success. When a split produces classes that have no independent reason to change — a Validator and an Applier that are always edited in the same PR, a Mapper that exists only to move three fields — you have lowered cohesion, not raised it. The cost is real and it is paid on every read: indirection without information. To answer “what does placing an order do?” you now open eight files and hold their interaction in working memory, because the behaviour was shattered across them with no boundary that means anything.
// Over-applied SRP: one cohesive operation atomised into delegating shells.
class OrderValidator { validate(o: Order) { /* one if */ } }
class OrderPriceApplier { apply(o: Order) { /* one line, calls Calculator */ } }
class OrderTotaller { total(o: Order) { /* one sum */ } }
// placeOrder() now orchestrates seven of these. Reading it is reassembly.The senior move is to recognise both shapes as the same defect — wrong cohesion — pointing in opposite directions. A god class is many reasons to change crammed into one box; a class explosion is one reason to change smeared across many boxes. Neither maps the module boundary to a real change boundary. SRP done well lands between them: as few modules as the distinct actors require, and no fewer.
One report generator, split three ways — only one split is real. Start with a class that builds a sales report:
class SalesReport {
// (a) which rows to include — changes when finance redefines "sale"
private select(orders: Order[]): Order[] { /* finance policy */ }
// (b) the running totals — same invariants, same fields as (a)
private subtotal(rows: Order[]): Money { /* always edited with (a) */ }
// (c) render to HTML — changes when design refreshes the template
private toHtml(rows: Order[], total: Money): Html { /* design policy */ }
}The shattering split (wrong). A dogmatic reading produces five classes: RowSelector, Subtotaller, TotalFormatter, HtmlRenderer, ReportOrchestrator. But select and subtotal always change together — redefining a “sale” changes both which rows count and how they sum. They share the same finance invariant. Splitting them means every finance change is now a two-file edit kept in sync by hand. You created shotgun surgery and added an orchestrator nobody asked for.
The balanced split (right). There are exactly two actors here, so there are two modules:
// Finance actor: what counts as a sale and how it sums. One reason to change.
class SalesFigures {
compute(orders: Order[]): { rows: Order[]; total: Money } {
const rows = this.select(orders);
return { rows, total: this.subtotal(rows) };
}
private select(orders: Order[]): Order[] { /* … */ }
private subtotal(rows: Order[]): Money { /* … */ }
}
// Design actor: how the figures are presented. A different reason to change.
class SalesReportView {
render(figures: { rows: Order[]; total: Money }): Html { /* … */ }
}select and subtotal stay together because they are one responsibility (the finance figures) — keeping them cohesive. Rendering splits off because it answers to a different actor on a different cadence. Two real change boundaries, two modules. A finance change touches SalesFigures only; a template refresh touches SalesReportView only; neither disturbs the other, and nobody reassembles five fragments to understand one report. The split tracks observed reality, not the rule book.
▸Why this works
Why is “different actors / different cadence” a better test than “does it do more than one thing”? Because everything does more than one thing if you zoom in far enough — withdraw checks a balance and subtracts. Decomposed infinitely, every method is “multiple responsibilities,” which is why the naive reading dead-ends in class explosion. “Reason to change” cuts the regress: you stop splitting when the remaining pieces would always change together, because there is no future change that would want them apart. The boundary is defined by change, which is finite and observable (read the commit history), not by behaviour, which subdivides without end. This is also why SRP is an economic principle, not an aesthetic one — it is cost-of-change again, scoped to module boundaries.
▸Common mistake
The dogmatic-split trap: treating “one class, one method” or “one file, one verb” as the goal and refactoring toward it mechanically. The tell is a layer of -er classes — Validator, Mapper, Applier, Orchestrator — that have no independent reason to change and always appear together in commits. A second tell: to follow one feature you keep jumping between files, each of which does a single statement and delegates. If you cannot answer “what distinct future change would touch this module and not its neighbour?”, the split is invented, and you should merge the shards back into the cohesive concept they were carved from. Splitting is not free; each boundary you add is indirection a future reader must pay to cross. Add a boundary only where a real change boundary already exists.
A teammate split an Account class into AccountValidator (checks the balance isn't exceeded) and AccountApplier (subtracts the amount). In the git history, every change to the overdraft rule edits both files together. Is this a good SRP split?
SRP optimises cohesion, and cohesion has a sweet spot with a failure mode on each side: a god class crams many actors into one module, while a class explosion smears one actor across many. Both are the same defect — module boundaries that do not match real change boundaries — pointing in opposite directions, so the cure for one is never just “more splitting.” The reliable tests are about change, not size: split when two responsibilities have independent change cadence or answer to different actors; keep together what always changes together and shares state and invariants. Read the commit history — a real seam shows up as regions that change for different reasons. Aim for as few modules as the distinct actors require, and no fewer; defend every split by naming the distinct future change it makes cheap, and merge any shard you cannot defend that way.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.