Single responsibility principle (SRP)
Open in the interactive app: practice, quiz, mock interview →In one minute
- A module should have one reason to change. "Reason" means a person or group who can ask for the change: finance owns tax rules, marketing owns email copy, the platform team owns the mail provider. Code that serves three of them has three reasons to change.
- The test is not "does this class do one thing?" (everything does one thing at some zoom level). The test is "if two different people asked for changes this week, would they both edit this file?"
- Splitting along those lines buys isolated tests, smaller diffs, fewer merge conflicts and fewer surprise regressions. It costs indirection: more files, more names, more hops to follow when reading.
- Split along axes of change you have actually seen, not ones you imagine. A one-method class per verb is as hard to work with as a 2,000-line god class.
- With agents writing code, SRP becomes a review tool: a change to tax rules that also touches the email template and the retry loop is a diff you cannot review with confidence.
How it works
A realistic "before"
Most SRP violations are not written on purpose. They grow. Here is a billing service after two years of feature requests:
class InvoiceService:
def issue(self, order_id):
order = self.db.query("SELECT ... FROM orders WHERE id = %s", order_id)
subtotal = sum(l.price * l.qty for l in order.lines)
if order.country == "DE":
tax = round(subtotal * 0.19)
elif order.country == "ID":
tax = round(subtotal * 0.11)
else:
tax = 0
html = f"<h1>Thanks, {order.name}!</h1><p>Total: {subtotal + tax}</p>"
for attempt in range(3):
try:
self.smtp.send(order.email, "Your invoice", html)
break
except TimeoutError:
time.sleep(2 ** attempt)
self.db.execute("INSERT INTO invoices ...", order_id, subtotal, tax)
self.metrics.incr("invoices.issued")Count who might ask for a change. Finance wants a new tax rule. Marketing wants a new template. The platform team wants to move from SMTP to a provider API. The data team wants the invoice row written before the email, so a crash cannot send an email for an invoice that does not exist. Four owners, one method. Each change risks the other three, and the only way to test the tax arithmetic is to fake a database, an SMTP server and a clock.
The "after"
Split by owner, then put a thin coordinator on top:
class TaxPolicy: # finance
def tax(self, subtotal, country): ...
class InvoiceRenderer: # marketing
def render(self, invoice): ...
class InvoiceRepository: # data
def get(self, invoice_id): ...
def save(self, invoice): ...
class Notifier: # platform: retries, provider choice
def send(self, to, body): ...
class IssueInvoice: # the workflow: order of steps only
def run(self, order): ...IssueInvoice still knows the sequence (compute, save, then notify) because the sequence is its responsibility. It does not know tax rates, HTML or retry backoff. Tax tests become pure arithmetic that run in microseconds. The notifier's retry policy can change without anyone rereading tax code.
Where the name misleads
"Single responsibility" sounds like "do one thing", which leads people to split by verb: InvoiceLoader, InvoiceSaver, InvoiceValidator, InvoiceFormatter. That often splits one owner's logic across four files, so every change still touches all four. Robert Martin later restated the principle as "gather together the things that change for the same reasons; separate the things that change for different reasons." That is cohesion and coupling under another name, and it goes back to David Parnas's 1972 advice to hide each design decision likely to change inside its own module.
When following it too zealously hurts
- Indirection without payoff. If tax rules have changed once in three years and only one team ever touches the file, a
TaxPolicyFactoryadds reading cost and no protection. - Scattered invariants. "An invoice total equals subtotal minus discount plus tax" is one rule. Spread across a calculator, a validator and a mapper, nobody owns it and it drifts.
- Anaemic objects. Splitting all behaviour out of a type leaves a bag of fields plus a cloud of
*Serviceclasses that each poke at it. - Premature seams. Every interface you add is a promise to keep it stable. Add one when a second reason to change shows up, not before.
A practical rule: extract when you see two different owners in the commit history of one file, or when a test needs fakes for things unrelated to what it checks.
Reviewing agent-written code
Coding agents optimise for "make the requested change work" and tend to put the change where the context already is. Asked to add a tax rule, an agent will happily add it inside issue(), and while there it may also "fix" the retry loop it noticed. Two habits help:
- Ask of every diff: which owner asked for this, and does the diff touch code owned by anyone else? A diff that spans several owners should be split into separate changes.
- Prefer codebases where responsibilities are already separated. An agent working in a 40-line
TaxPolicywith its own tests has less room to cause collateral damage than one working in a 1,400-line service.
The same shape elsewhere
A god class is a contention problem. Many teams writing one file looks like many transactions writing one hot row: they serialise on merge conflicts instead of locks. The fix is the same in both domains: partition the shared thing by who writes it.
Down the stack
Layer 1The contract
After the split, each unit has a contract small enough to test exhaustively. TaxPolicy.tax(subtotal, country) is a pure function: same inputs, same output, no I/O. InvoiceRepository.get returns the saved invoice or "not found". Notifier.send either delivers or returns an error. IssueInvoice promises ordering: it saves before it notifies, never notifies if saving failed, and does not send twice for an invoice that already exists. Each sentence maps to a test that needs at most two fakes.
Layer 2The mechanism
The mechanism is dependency injection plus module boundaries. IssueInvoice receives its collaborators through its constructor instead of building them, so tests pass fakes and production passes real adapters. In Python the boundary is a module and an informal protocol (or typing.Protocol). In TypeScript it is an interface checked structurally at compile time and erased at runtime. In Go it is a package plus a small interface declared by the consumer, so billing declares type Notifier interface { Send(to, body string) error } and the email package never imports billing. Import graphs make the split visible: if tax imports smtp, the split is cosmetic.
Layer 3The machine
Splitting costs a little at runtime. Each collaborator is one heap object, typically 16 to 64 bytes, allocated once at startup and reused, so allocation cost rounds to zero. Calls through an interface are indirect. In Go an interface value is two words (an itab pointer and a data pointer), and a call loads the function pointer from the itab and jumps: about 1 to 2 ns, and it blocks inlining unless the compiler can devirtualise it (profile-guided optimisation in Go 1.21+ does this for hot sites). In V8 a monomorphic call site is cached and costs a few ns. In CPython any method call costs roughly 30 to 100 ns. Compare that with a 1 ms database round trip or a 200 ms email API call: the dispatch overhead is under a thousandth of the request.
Layout and concurrency are where the split can make code faster. A god object that holds a rate cache, retry counters and a template cache behind one mutex serialises unrelated work: a template reload blocks tax calculation. Separate objects get separate locks, or no locks at all when they hold no mutable state. Separate objects also stop unrelated hot counters from sharing a 64-byte cache line, which avoids false sharing when two threads update them.
Why it's built this way
- Requirements arrive from different people at different times, so code changes along those organisational lines.
- When one file mixes several owners' logic, every change has to be checked against all of them, so review and test cost grows with the number of owners.
- Isolating each owner's logic behind a narrow contract makes the blast radius of a change equal to one owner's code.
- Isolation needs seams (interfaces, injected collaborators), and seams cost reading time and a few nanoseconds per call.
- So split where change actually happens, and keep everything else together.
Diagram
flowchart LR
API["POST /invoices"] --> W["IssueInvoice (workflow)"]
W --> T["TaxPolicy (finance)"]
W --> R["InvoiceRepository (data)"]
W --> N["Notifier (platform)"]
W --> V["InvoiceRenderer (marketing)"]
R --> DB[("Postgres")]
N --> MAIL["Email provider"]Worked example
A team's InvoiceService is 1,400 lines. Over one quarter it had 37 commits from 4 teams and 11 merge conflicts. Its test suite starts a Postgres container and a fake SMTP server and takes 40 seconds, so developers run it rarely. Two production bugs that quarter were "fixed the template, broke the tax rounding" changes.
After the split: TaxPolicy is 60 lines with 120 table-driven tests that run in 0.2 seconds. The renderer has snapshot tests. The repository has integration tests against a real database, run in CI only. IssueInvoice is 35 lines with 6 tests using in-memory fakes. Commits per file per quarter drop to single digits and conflicts to roughly zero, because the teams no longer edit the same files.
The runtime cost: four extra objects at startup and four interface calls per invoice, about 10 ns in Go, against a request that spends 3 ms in the database and 150 ms waiting for the email provider. Nobody will measure the difference.
What the team chose not to split: the rule that an invoice's total equals subtotal plus tax stays inside one Invoice type with its constructor enforcing it. One rule, one place.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| One cohesive class | Easy to read top to bottom; no indirection | Every owner edits it; tests need every dependency | One owner, small code, stable requirements |
| Split by owner (reason to change) | Small diffs, fast pure tests, fewer conflicts | More files and names; a coordinator to maintain | Several teams or roles change the code |
| Split by verb (load, validate, save) | Looks tidy | One change still touches every piece | Rarely; it usually mirrors a framework, not a need |
| Split into services | Independent deploys and scaling | Network calls, partial failure, distributed consistency | Owners need separate release cycles, not just separate files |
Pitfalls in production
- Splitting the code but sharing the database tables, so finance and marketing still collide on schema migrations. Fix: give each responsibility its own tables or at least its own migration owner.
- A "workflow" class that slowly re-absorbs logic ("just one quick check here"). Fix: keep it to sequencing and error handling; push rules down into the owner's module.
- Side effects in the wrong order (email sent, then the save fails). Fix: the coordinator saves first, then notifies, and checks for an existing invoice so a retry does not send twice.
- Interfaces with one implementation and no test that uses a fake. Fix: if nothing substitutes it, inline it until something does.
- Shared mutable "context" objects passed to every collaborator, which recreate the god object one level down. Fix: pass each collaborator only the values it needs.
Leadership lens
- Map code ownership to responsibilities. If CODEOWNERS for one file lists four teams, that file is a scheduling and quality risk, not just a style issue.
- In design review ask "who asks for this to change?" rather than "is this class too big?". Size is a symptom; mixed owners are the cause.
- Budget refactors where the commit history shows conflict and regressions, not across the whole codebase. The payoff is concentrated in a few hot files.
- For agent-assisted work, require one owner per change. Ask agents for focused diffs and reject ones that wander across responsibilities, even when each edit looks correct.
Check yourself
A
ReportGeneratorclass loads data, computes totals and renders a PDF, and only the analytics team has ever changed it. Does it violate SRP?Show answer
Probably not in a way that matters. It has one owner and one reason to change so far; splitting it now adds indirection without reducing risk. Revisit if a second owner appears.
Why does splitting by verb (loader, validator, saver) often fail to deliver SRP's benefits?
Show answer
One owner's change, such as a new field, usually has to touch loading, validation and saving together, so the change still spans every file and the pieces stay tightly coupled.
What does the coordinator in the "after" design own, and what should it never contain?
Show answer
It owns the sequence and the error handling between steps (save before notify, skip duplicates). It should not contain tax rules, templates or delivery retries, which belong to their owners.
How does a god class make concurrency worse?
Show answer
Unrelated state sits behind one lock or one object, so unrelated operations serialise on it and hot fields can share cache lines; splitting by responsibility allows separate locks or no shared state at all.
Watch
- Breaking Dependencies: The SOLID Principles (Klaus Iglberger, CppCon 2020): a careful look at what each SOLID principle really says, including SRP as separation of concerns.
- SOLID Object-Oriented Design (Sandi Metz, GORUCO 2009): refactors a real script step by step and shows when each extraction pays for itself.
- Uncle Bob's SOLID Principles Made Easy, in Python (ArjanCodes): a short Python refactor of an order-processing class.
Free resources
- The Single Responsibility Principle (Robert C. Martin): the author's own restatement in terms of people and reasons to change.
- On the Criteria To Be Used in Decomposing Systems into Modules (David Parnas, 1972): the original argument for hiding each likely change behind a module.
- CUPID: for joyful coding (Dan North): a critique of SOLID and an alternative set of properties worth comparing.
- Beck Design Rules (Martin Fowler): a counterweight that ranks "fewest elements" alongside clarity.
- Conway's Law (Martin Fowler): why module boundaries end up following team boundaries.