SOLIDCONTESTEDPARADIGM-SPECIFIC

Single Responsibility, Critically

"A coherent reason to change" is the useful reading. "One thing" is the one that produces a hundred classes that each do nothing.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

What counts as one responsibility, when every class can be described as doing one thing or as doing five?

The requirement

InvoiceService calculates invoice totals, renders a PDF, emails it, and writes the record. Finance change the tax rules, design change the PDF layout, and marketing change the email copy. All three teams edit the same file, and every release something unrelated breaks.

The obvious build

Leave it as one service. Everything about invoices is in one file, so a new engineer can read the whole story top to bottom, and there is no indirection between the calculation and the output. For a small system this is genuinely the most readable arrangement, and splitting it early would have been over-design (When Design Does Not Pay).

Why it breaks

The three teams collide. Not occasionally — structurally, because every one of them must edit the same file to do their job, which is a coordination cost that grows with headcount and never with the code (Divergent Change).

How it breaks as requirements change
  • The three teams collide. Not occasionally — structurally, because every one of them must edit the same file to do their job, which is a coordination cost that grows with headcount and never with the code (Divergent Change).
  • The audit trail is polluted: the tax calculation's commit history is interleaved with email copy tweaks, so "when did this rule change" takes an hour instead of a minute.
  • Testing the tax rules requires a PDF renderer and a mail transport on the path, so those tests are slow and get skipped (Testing as Design Feedback).
  • Blast radius: a layout change has twice shipped a total change, because both live in one class and the tests that would have caught it were the slow ones.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • Three teams with three release cadences edit this file today; that is the actual pain and any design has to address it.
  • The tax calculation is legally auditable, so its change history must be readable without PDF layout commits in the way.
  • The team has previously split a class into eleven and hated it, so "split it up" is not an argument that will land on its own.
  • The invoice record write and the total calculation must stay transactionally consistent, which constrains how far apart they can move.
Invariants
  • The number written on the PDF, the number in the email and the number in the database are the same number, however the code is arranged.
  • A change to PDF layout can never change a total. Today nothing enforces that, which is why it has happened twice.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Invoice calculation owns the numbers, the tax rules and their audit trail. It is the piece with a legal obligation and it should be boring, pure and heavily tested.
  • Invoice rendering owns turning a calculated invoice into a document. It changes when design changes and never when tax law does.
  • Delivery owns getting a rendered invoice to a customer — email today, a portal download tomorrow.
  • Persistence owns the record. It stays close to calculation because of the transactional invariant, which is a case of a constraint overriding a clean split (Consistency Boundaries).
Boundaries
  • The seams fall on the lines between the three teams, because "who asks for the change" turns out to be the most reliable operational meaning of "reason to change" — this is why Martin's later restatement uses "actor" rather than "reason".
  • The seam does *not* fall between calculation and persistence, even though a naive split would put it there. The invariant binds them, and boundaries that cut across an invariant produce the worst kind of coupling (Where Invariants Live).
  • Four pieces, not eleven. The stopping rule is that each resulting unit still does something a stakeholder would recognise as a thing (Over-Decomposition).

Count the reasons, do not argue about them

The word "responsibility" is where every SRP argument dies, because both readings of any class are defensible. The way out is to stop arguing about the noun and look at the history: what has actually changed in this unit, and who asked for it. That is observable, it is in git log, and it settles the question in a way no definition has managed to.

Laid out as a responsibility analysis, this class does not need an opinion to convict it. Four independent reasons to change, three of them belonging to different teams, is the finding.

responsibilitiesInvoiceService
Knows
  • Tax rules by jurisdiction and product category
  • The PDF layout: fonts, margins, where the logo goes
  • The SMTP configuration and the email template
  • The invoices table schema and the transaction boundary
Does
  • Computes line totals, tax and grand total
  • Renders a PDF document
  • Sends an email with the PDF attached
  • Writes the invoice record and commits
Depends on
  • A tax rate table
  • A PDF library
  • An SMTP client
  • The database connection
Changes when — 5 distinct reasons
  • Finance change a tax rule (audited, roughly monthly)
  • Design change the invoice layout (roughly quarterly)
  • Marketing change the email copy (roughly monthly)
  • A schema migration changes the invoice table (rarely)
  • The PDF library has a breaking release (rarely, on someone else's schedule)

Five reasons to change, driven by three different teams plus an external vendor. This is not a judgement call about what "one thing" means — three groups of people with three cadences are structurally required to edit one file, and that is a merge point and a regression source by construction. Split by requester into calculation, rendering and delivery; keep the record write next to calculation because a transactional invariant binds them (Consistency Boundaries).

The misuse: splitting until nothing does anything

The failure mode is not theoretical and it is more common than the god object it was meant to prevent. Applied as "one class, one thing", SRP has no stopping rule, so teams keep going: a class per method, then a class per step, then interfaces for each, and the invoice logic ends up distributed across eleven files that each forward to the next.

The tell is that no single file answers a question anyone has. Understanding what an invoice total is requires opening every file in the chain, and each one contributes two lines. The knowledge did not get organised; it got scattered, and this is a worse outcome than the original class (Over-Decomposition).

Two splits of the same class
Split by "one thing"
invoice/
  InvoiceTotalCalculator.ts       // calls the next three
  LineSubtotalCalculator.ts      // sum(qty * price)
  TaxCalculator.ts               // calls TaxRateResolver
  TaxRateResolver.ts             // reads a table
  DiscountApplier.ts             // one if statement
  RoundingStrategy.ts            // Math.round, behind an interface
  InvoiceTotalCalculatorFactory.ts

// "What is the tax on a German B2B order?"
// -> open five files, none of which contains the rule,
//    each of which contains one line of it.
Split by reason to change
invoice/
  calculation/   // finance changes this. Pure, audited, no I/O.
    total.ts     //   ~150 lines: lines, discounts, tax, rounding.
    rates.ts     //   the rate table, versioned by effective date.
  rendering/     // design changes this.
    pdf.ts
  delivery/      // marketing changes this.
    email.ts

// "What is the tax on a German B2B order?"
// -> one file, read it top to bottom.

The right-hand version has fewer, larger units and answers the question in one place, which is the property that actually matters — a reader forms a complete picture without navigation. The left-hand version optimised for a metric (methods per class) that nobody was paying, while making the thing people do daily (understand a total) strictly more expensive. Both satisfy "one thing" under some description; only one of them makes a change cheaper, and change cost is the only judge this domain recognises (The Cost of Change).

The five-part reading

Stated the way this module states every principle: the problem it addresses, an example where it genuinely earns its cost, the misuse, the trade-off, and a real design that ignores it and is right to.

The counterexample matters most, because it is the part checklists never include, and because anyone who has worked in a well-built system has seen one.

  • Problem it addresses — a unit that several stakeholders must edit for unrelated reasons is a permanent merge point, a regression source and an unreadable change history.
  • Useful example — the invoice split above: three teams, three cadences, one file, and a layout change that twice altered a total.
  • Misuse — "one class, one thing" applied with no stopping rule, producing eleven files that each hold one line of a rule (Over-Decomposition).
  • Trade-off — every split makes independent changes cheaper and additive changes more expensive, and couples the code structure to an organisation chart that will change.
  • Counterexample — a well-written parser, a Money type, a date library: single-file, wide-surfaced, dozens of methods, one stakeholder, changed rarely and coherently. Splitting them by "one thing" would produce nothing but navigation (What an Abstraction Actually Is).
smellClass that only forwards

looks like A class whose every method is one line: return this.next.doThing(x). Often named ...Manager, ...Handler or ...Coordinator, often introduced during a split, often with an interface of its own.

suggests A decomposition that went past the point of usefulness. The class adds a name and a file and no behaviour, so every reader pays navigation cost for nothing, and the "responsibility" it holds cannot be described without using the word "coordinates" (Over-Decomposition).

fix Inline it and call the target directly. If several such classes form a chain, collapse the chain and see what is left; usually one module with a real interface. Before deleting, check it is not one of the four legitimate cases above — the distinguishing question is whether anything would be *lost* other than a hop.

when this is fine Genuinely correct when the forwarding is the point: an adapter translating one vocabulary into another, a facade deliberately narrowing a wide subsystem for a specific consumer, an anti-corruption layer keeping a vendor model out of the domain, or a decorator adding caching, retries or logging around a call. In all four the class forwards *and* does something at the boundary — translate, narrow, protect, augment — and the file earns its place (Adapter, Facade, Anti-Corruption Layer, Decorator).

How to build it

Most important first.

  • List the changes this file has actually received in the last year and who asked for each. This takes twenty minutes with git log and replaces an entire category of argument with evidence.
  • Group the changes by requester. Each group is a candidate responsibility, and the groups are visible rather than debatable.
  • Split along those groups, and stop. Resist splitting further along technical lines nobody has ever changed independently.
  • Keep pieces together where an invariant binds them, even when the split would look tidier. An invariant that spans two units has to be enforced by coordination, which is worse than a slightly wide unit (Enforcing Invariants).
  • Check the result against the actual pain: can the three teams now work without touching each other's files? If not, the split was cosmetic (Change Amplification).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: any of the three teams' changes costs one edit to a shared file, a full invoice regression suite, and a merge negotiation if two land in the same week. The regression suite is slow because it renders PDFs.
  • After: a tax change costs one edit to a pure module and a fast test run, with an audit-clean commit history. A layout change cannot alter a total, because it cannot reach the code that computes one.
  • What got more expensive, and it is not nothing: adding a field that flows from calculation through rendering to email now touches three modules and a data type, where before it touched one file. Additive changes get worse; independent changes get better (Change Amplification).
  • The next *structural* change — a second delivery channel — is now local to delivery, which is the change this split was actually bought for.
What the recommended approach costs
  • Four modules means four files, four names, and a sequencing step that did not exist. Reading the whole invoice story now requires navigation that reading one file did not (Local Reasoning).
  • Splitting by requester couples the code structure to the organisation chart, which changes. A reorg can make yesterday's perfect split arbitrary — a real and underappreciated risk (Code Ownership).
  • The additive-change tax is permanent, and on a codebase whose changes are mostly additive it can exceed the benefit.

What can go wrong

Failure modes
  • The split is done by technical layer instead of by requester — InvoiceCalculator, InvoiceMapper, InvoiceValidator, InvoiceHelper — and the three teams still all edit all four (Decomposition by Folder).
  • Over-splitting: eleven classes, each with one method, each calling the next, and understanding an invoice now requires opening all eleven (Over-Decomposition).
  • The mitigation fails when a coordinating service quietly absorbs the logic that was supposed to be distributed, so the god object moves rather than disappears (God Object).
  • A shared InvoiceUtils appears to hold the bits that did not obviously belong anywhere, and becomes the new collision point (The Utility Dumping Ground).
Dependencies, and their direction
  • Rendering depends on the calculated invoice — a data type — and not on the calculator, so the tax rules can be changed without recompiling the renderer.
  • Delivery depends on rendering's output, again as data. The chain is linear and points from volatile presentation toward stable calculation (Dependency Direction).
  • An application service depends on all four and sequences them. It has high fan-out and no logic, which is the correct shape for a sequencer.
Misreads
  • "A class should do one thing." The most damaging sentence in this area. "One thing" is scale-free — an invoice system does one thing, and so does a getter — so the rule licenses splitting to any depth, and people take it to the depth where nothing does anything (Over-Decomposition).
  • "One reason to change means one method." A class with fifteen methods that all change when tax law changes has one reason to change and is correct. Method count is not the measurement (Long Functions makes the same argument about lines).
  • "So we should split this class." Maybe. First check whether it has actually received changes for different reasons. Many wide-looking classes have a single stakeholder and have never been a collision point, and splitting them is pure cost (YAGNI, With Its Bill Attached).
  • "SRP means small classes." It means coherent ones. A small class that changes for three reasons violates it; a large one that changes for one does not (Cohesion).
Smells this explains
  • divergent-change
  • god-object
  • utility-dumping-ground

Testing it, and how it ages

What to test, and at which boundary
  • Calculation gets pure, fast, thorough tests, including the historical cases the auditors care about. This is where the test budget should go and the split is what makes it possible (What a Unit Is).
  • Rendering gets snapshot or golden-file tests, which are cheap and catch layout regressions without touching tax rules (Characterization Tests).
  • One integration test that the number on the PDF equals the number in the database, because that is the invariant and it now spans modules.
  • Do not write a test per extracted class reflexively. A test suite that mirrors the class structure is a test suite that will break on the next refactor without catching anything (Mocking).
How this design ages
  • Calculation grows internal structure — tax strategies, rounding policies — and stays one responsibility from the outside. Growth inside a boundary is success, not drift.
  • Delivery is the piece most likely to multiply: email, portal, webhook, print. That is the anticipated axis and it is where the split pays a second time.
  • The design stops fitting if invoices diverge by product line to the point that "an invoice" is no longer one concept. Then the split is along the wrong axis and should be by product line, with each owning its own calculation and rendering (Vertical Slices).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • CONTESTEDThe strongest opposing case: "reason to change" is not observable in advance, so in practice SRP licenses whatever split the speaker already preferred, and the empirical result across many codebases is more classes, more indirection and no measurable reduction in change cost. Critics with large-system experience argue that a smaller number of larger, cohesive modules with deep interfaces is cheaper to work in than many shallow ones — Ousterhout's "deep modules" argument is the best-developed version, and it explicitly identifies aggressive decomposition as a leading cause of complexity. That argument is strong and this lesson concedes most of it; what survives is the narrow, checkable version: when two different stakeholders have repeatedly edited the same unit for unrelated reasons, that is observable history rather than speculation, and it is worth acting on.
  • PARADIGM-SPECIFICIn a functional codebase SRP is mostly what a function already is, and the interesting unit is the module: the same question becomes "does this module's public surface serve one purpose". The class-splitting mechanics do not transfer, and applying them produces single-function modules with no benefit. In a data-oriented or ECS design the axis is inverted entirely — behaviour is grouped by system and data by component, and "one class one responsibility" is not a meaningful statement about either.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — "these tests need a PDF renderer to check a tax rule" is how this problem announces itself long before anyone names a principle.