Class design and SRP: I stopped splitting files until I could name the reason
James Okonkwo
September 18, 2026
I used to split a class the way some people split a service: as soon as it had two verbs. Invoice became InvoiceValidator, InvoiceCalculator, InvoiceNotifier, InvoiceRepository, and a InvoiceFacade that did what the old class did, plus a hop. Uncle Bob’s Single Responsibility Principle was the warrant. I had read the blog posts. I had not yet sat with a change that needed six files to add a field.
I still believe a module should have one reason to change. I no longer believe “reason to change” means “one method” or “one noun.” I stopped splitting files until I could name the reason in a sentence a teammate would recognize — a person, a cadence, or a failure mode. If I cannot name it, I leave the code together and I wait.
What SRP actually bought me when I used it loosely
The useful version of SRP is about who shows up when the code must move. If tax rules change because finance published a new memo, that is a different reason than “Stripe added a webhook type,” which is a different reason than “the PDF looks wrong in Germany.” Those reasons can live in one invoice object for a while. They start to hurt when two of them change on different calendars and the same 400-line class becomes a merge magnet.
I will split when I can say: “This file changes when X changes, and X is owned by Y, and Y’s schedule is not the rest of the app.” That is an SRP I can defend in a review. “This class has two public methods” is not.
Robert Martin’s original phrasing was about actors — the people who request change. I ignored the actors and counted functions. That is how I got a folder of nouns: CreateInvoice, UpdateInvoice, VoidInvoice, each a class with one method, each importing the same helpers, each a file I had to open to understand a single user action. That is not cohesion. That is a Java stereotype I imported into a Python and a Ruby codebase that did not ask for it.
The last time I waited, and the last time I should not have
In a Go service I kept a BillingService struct that started a subscription, recorded a payment, and emitted a domain event. A colleague wanted three interfaces. I asked who would change payment recording without changing subscription start. The answer was “maybe later.” We left it. Six months later the file was longer and still one owner. We extracted a Ledger when we added a second processor and the ledger had to stay correct if Stripe was down. That was a named reason: a second actor (the other processor) and a failure mode (we must not double-book). The extract was a day’s work because the types were already honest.
The time I should not have waited: a Django Order model with signals that sent email, hit inventory, and called Avalara. Three vendors, three outage modes, one transaction that lasted until the last HTTP call. I had “kept it simple.” Support had a pile of half-orders. The split there was not aesthetic. It was “HTTP does not belong in the commit.” I moved side effects to Celery after the commit. I did not invent OrderEmailer, OrderInventory, and OrderTax as objects for their own sake. I invented a job per side effect because the side effects failed independently. That sentence is the reason.

How I name a reason so it is not a vibe
I write the reason on the PR, not in a comment that says “SRP.” Useful names look like this:
- “Changes when Stripe’s subscription object changes.”
- “Changes when the PDF layout changes; finance does not care.”
- “Changes when we add a locale; copy lives here, money math does not.”
- “This is the only place we talk to Avalara. If Avalara is down, this is the file I want in the stack.”
Useless names look like this:
- “This class was getting long.”
- “We should be more SOLID.”
- “I extracted an interface for testability” (when the test could have used a function).
- “Clean architecture says the use case is a class.”
Length is a smell, not a verdict. I have kept a 300-line parser together because the grammar was one reason. I have split a 80-line class because half of it was a clock and half of it was HTTP and the tests were lying with sleeps. The clock was a reason: time is a dependency I want to inject so I can freeze it. That is SRP as testability with a name, not as a religion.
Functions are allowed. So are modules. Classes are not the only unit
In Python I will often extract a function in the same file before I extract a class. _normalize_tax_id does not need to be TaxIdNormalizer. In Go, a package is the unit I care about more than a type. In TypeScript, a file of functions plus a couple of types is a better “class” than a folder of services that close over nothing. I use a class when I have state that must stay consistent — a connection pool, a running calculation, a machine. I do not use a class because a tutorial said objects are grown-up.
Kotlin and Java shops will have more types. Fine. I still want the type to match a reason. A record or a data class for a DTO is not SRP theatre. A FooManager that exists to hold three collaborators I could have passed as arguments is theatre.
I am also done with “manager / helper / util / service” as a split strategy. Those words hide the reason. StripeWebhookParser is a reason. WebhookHelper is a junk drawer waiting for a second vendor.
The folder of nouns, and how I design now
When I inherit a usecases/ directory with one class per verb, I do not immediately merge them. I read a week of git blame. If five files always change together, I merge those five. If one file changes when a vendor changes and the others do not, I keep the split and I rename it after the vendor. Co-change is the empirical SRP. Martin’s actors show up in the blame if you look.
New design, for me, starts at the use case as a script, not as a class diagram. I write the happy path in one function: load, decide, save, enqueue. Then I notice the decide that is gnarly — proration, dunning, a state machine — and I give that a type if it has invariants. I notice the save that talks to two stores and I decide if that is one transaction or a job. I do not start with InvoiceController → InvoiceService → InvoiceRepository as a ritual. That ritual is how you get three files that always change together and a fourth interface that has one implementation.
Repositories: I use them when I have two storage backends or when the query language is leaking into every caller. I do not use them to wrap a single Active Record or a single sqlc query with an interface “for the future.” The future is usually one implementation and a harder jump-to-definition.

Testing without multiplying types
The other warrant for splits is tests. I want to test money math without hitting Stripe. That does not require twelve classes. It requires the math to be a function of values I can construct in a test. I will extract prorate(previous, next, period) long before I extract ProrationService. If the HTTP client is the problem, I pass a port — a small interface with one method — at the edge, not through every layer.
I have used Mockito to mock a forest of interfaces I created for SRP. The tests were green and the design was a maze. These days I prefer a fakes package with an in-memory ledger and a recorded HTTP client (VCR, vcrpy, go-vcr, or a handwritten stub). Fewer types. More honesty about what the system does.
A personal checklist before I hit “new file”
- Can I name the reason to change in one sentence that mentions a person, a vendor, a cadence, or a failure?
- Will this split make a typical change touch fewer files, or more?
- Is the new type hiding a missing function or a missing transaction?
- Am I splitting because the file is long, or because two clocks already disagree?
- Can a new hire find the behavior without opening a facade?
If I cannot clear (1) and (2), I do not split. I add a comment or a heading in the file — in languages that allow it — and I move on. A well-named section in a 200-line file is cheaper than a package that exists to satisfy a podcast.
If I clear all five, I split and I delete the extra adjectives. No Impl. No Base. No Abstract until I have two concretes. I have broken that last rule for frameworks. I try not to break it for my own code.
What I tell juniors who have just read the SOLID poster
Read it. Then open our last five production incidents and ask which file changed to fix them. If the same file shows up for tax, for Stripe, and for PDF, we have a cohesion problem — or we have a small app and one owner, which is fine. If every incident required a treasure hunt across *Service types, we have an SRP problem of the opposite kind: too many reasons hidden behind too many doors.
I would rather you ship a slightly thick class with a good test than a set of nouns that cannot run unless the container wires them. We can always cut a seam later. We cannot always find the behavior later.
SOLID is a set of names for tensions that already exist: change, extension, substitution, fat interfaces, glue. SRP is the tension I see most abused because it is the easiest to fake. Splitting a file feels like design. Naming the reason is design. I do the second first now. The file count went down. The reviews got shorter. The incidents did not get worse. That is the only metric I kept.