Engineering Atlas › Low-Level Design

Single responsibility principle (SRP)

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

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:

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

  1. Requirements arrive from different people at different times, so code changes along those organisational lines.
  2. 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.
  3. Isolating each owner's logic behind a narrow contract makes the blast radius of a change equal to one owner's code.
  4. Isolation needs seams (interfaces, injected collaborators), and seams cost reading time and a few nanoseconds per call.
  5. 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

OptionStrengthsWeaknessesChoose when
One cohesive classEasy to read top to bottom; no indirectionEvery owner edits it; tests need every dependencyOne owner, small code, stable requirements
Split by owner (reason to change)Small diffs, fast pure tests, fewer conflictsMore files and names; a coordinator to maintainSeveral teams or roles change the code
Split by verb (load, validate, save)Looks tidyOne change still touches every pieceRarely; it usually mirrors a framework, not a need
Split into servicesIndependent deploys and scalingNetwork calls, partial failure, distributed consistencyOwners need separate release cycles, not just separate files

Pitfalls in production

Leadership lens

Check yourself

  1. A ReportGenerator class 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.

  2. 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →