Designing by Responsibility
Ask of every unit: what is this responsible for? If the answer needs the word "and" more than once, you have found the design problem before it found you.
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.
What question do I ask of a class, module or function to find out whether it is well designed?
A four-year-old codebase has a UserManager. Every new feature that touches users adds a method to it. Nobody has proposed changing this because nobody can say what is wrong beyond "it is big".
It has too many methods, so split it by method count: UserManagerA, UserManagerB. Or split it by verb: UserReader, UserWriter. Either way the file gets smaller and the review passes.
Splitting by size moves lines without moving reasons to change, so the next user-related requirement still edits two files instead of one — strictly worse (Shotgun Surgery).
- Splitting by size moves lines without moving reasons to change, so the next user-related requirement still edits two files instead of one — strictly worse (Shotgun Surgery).
- Splitting by verb produces a reader and a writer that both know the schema, both know the password policy and both change when either changes. The coupling was never about reads and writes.
- The invariants get lost in the move. "Verified before mail" was enforced by one method calling another inside the same object; after a naive split it is enforced by a convention that new code will not follow (Invariant Leaks).
- Worst of all, the exercise feels productive. The file is smaller and the design is identical, which makes the real problem harder to raise a second time.
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.
UserManageris imported in 61 places. Nothing can be deleted in one step.- Its methods are individually reasonable — each one was the obvious place to put that code at the time.
- Test coverage is decent but every test constructs the whole object, so any split breaks a lot of tests at once.
- A user's email is unique and verified before it can receive transactional mail.
- Password hashes never leave the module that owns them, in any form, including logs (Sensitive State).
- Whatever restructuring happens, no user's stored credentials change.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Identity owns the credential: hashing, verification, rotation. It is the only code that ever sees a password, and it exposes no getter for one.
- Profile owns mutable user-supplied data: name, avatar, locale. It changes when the product changes and has no security surface.
- Notification owns "how do we reach this person": address of record, channel preferences, suppression list. It changes when the messaging vendor or the compliance rules change.
- Access owns roles and permissions. It changes when the authorisation model changes, which is on an entirely different clock from everything above (Least Privilege as a Design Decision).
- Persistence owns storage. It changes when the schema changes and, crucially, is not a responsibility of any of the four above (State Ownership).
- The seams fall between the four owners, because each has a distinct external trigger: a security review, a product decision, a vendor change, a compliance rule.
- The boundary around identity is the strictest, because it is the one where a leak is a breach rather than a bug. It exposes verbs —
verify(candidate)— not data (Information Hiding). - Persistence is a boundary *under* all four rather than beside them: each owner names the data it needs, and one adapter satisfies them. That is what stops "who owns the users table" being the argument that blocks the whole refactor.
The question, asked of a real unit
This is not a caricature. UserManager is what a competent team produces in four years when every individual decision is locally sensible: the user is already loaded here, the transaction is already open here, the method is already private here.
The finding is not the list of methods. It is the changesWhen list — seven independent external triggers, each belonging to a different person in the business, all landing on the same file and all forcing a regression of the other six.
- — The users table schema
- — The password hashing parameters
- — Which roles exist and what they permit
- — The welcome and verification email templates
- — Which fields the CRM sync expects
- — The audit log format
- — Whether email verification is currently required
- — Validates registration input
- — Hashes and verifies passwords
- — Reads and writes user rows
- — Sends verification and welcome email
- — Charges the card on paid signup
- — Assigns default roles
- — Writes audit entries
- — Pushes a contact to the CRM
- — ORM / users table
- — bcrypt
- — SMTP client
- — Template engine
- — Payment SDK
- — CRM HTTP client
- — Audit logger
- — Feature-flag client
- — Clock
- — The password policy changes (security)
- — The schema changes (engineering)
- — Email copy or template changes (marketing)
- — The payment provider changes (finance)
- — A role is added (product)
- — The CRM is replaced (sales ops)
- — The audit format changes (compliance)
Seven triggers owned by seven different people. Any one of them redeploys code that handles credentials and charges cards, so the blast radius of a marketing copy change is identical to the blast radius of a password policy change. That is the finding — not the method count. The response is not "make seven classes": it is to move the two most volatile responsibilities (email copy, CRM sync) out first, and to move credentials out on security grounds regardless of how often it changes.
What the unit looks like once ownership is decided
The corrected unit is not smaller because someone cut it into pieces. It is smaller because four things that were never its job now belong to something else, and it has one trigger left.
Notice knows in particular. The redesigned identity module knows the hashing parameters and nothing about email, roles or the CRM — which is what makes "a password hash never leaves this module" a property you can actually enforce rather than a rule you hope people follow.
- — The hashing algorithm and its cost parameters
- — What makes a candidate password acceptable
- — When a hash needs rehashing at a higher cost
- — Hashes a new password
- — Verifies a candidate against a stored hash
- — Reports that a stored hash is due for rotation
- — A hashing library
- — A credential store port it declares itself
- — The security team changes the password policy or hashing cost
One trigger, one owner, and an interface made only of verbs — there is no way to obtain a hash from this module, which turns the "hashes never leak" invariant from a review comment into a property of the type. The cost is visible too: callers that previously did everything in one transaction now coordinate two modules, and someone has to decide what happens when the credential write succeeds and the profile write does not (Where the Transaction Boundary Goes).
The smell, and when the same code is correct
The pattern above has a name, and naming it is useful for review — but a name is where the conversation starts, not where it ends. Plenty of large units with many methods are exactly right, and treating the smell as a verdict is how teams end up shattering a perfectly good module.
The discriminator is always the same and it is never size: does this unit have one external trigger or several.
looks like One class or module that most of the codebase imports, whose method list reads like a table of contents for the whole feature area, and whose constructor takes six or more collaborators.
suggests Responsibilities accreted here because it was the place with the data already loaded. Expect several unrelated external triggers, a blast radius far larger than any individual change, and a test file that has to construct the world.
fix Do not split by size. List the external triggers, move the most volatile one out with its invariant, leave a delegating shell, migrate callers, delete the shell. If the trigger list has one entry, leave the module alone no matter how big it is.
Money type with forty operations, a parser, a protocol codec, or a domain aggregate that owns one invariant across many fields all have many methods and exactly one reason to change. The same is true of a deliberate facade over a subsystem whose job *is* to be the single entry point — there the wide surface is the design, not the accident (Facade).| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| The data is already loaded here | A reporting method appears on the entity that owns writes | Convenience beats ownership when nobody asks the question at review time | Ask "what is this responsible for" in review, before asking whether the code works (Review as Design Feedback — and Why It Arrives Too Late). |
| The transaction is already open here | An unrelated write is added inside an existing transaction | The transaction boundary silently became a module boundary | Decide the transaction boundary explicitly; do not let it be wherever the first write happened (Consistency Boundaries). |
| It is only three lines | A CRM call inside the registration path | Small additions are never individually worth a boundary, and they compound | Judge the addition by its trigger, not its size — three lines with a new external owner is a new responsibility (Kinds of Coupling). |
| Everything else is here | New engineers add to the biggest file because that is where similar code lives | Structure teaches; a god object teaches people to grow it | Make the alternative obvious and nearby — an empty, well-named module invites the next change more than a wiki page does. |
| The test helper already builds it | Tests construct the god object even for unrelated code | Test fixtures encode and then defend the current structure | Treat a fixture everything depends on as a design signal, not a convenience (Test Doubles, Precisely). |
How to build it
Most important first.
- Write the responsibility statement in one sentence, out loud, with no "and". "
UserManageris responsible for users" fails immediately, because "users" is a noun with six independent lifecycles attached to it. - List every reason the unit has to change, not every thing it does. Two units that do six things but change for one reason are fine; one unit that does two things and changes for five is not (Single Responsibility, Carefully).
- Group the reasons by who triggers them. Reasons with different triggers belong to different owners — that mapping from external trigger to internal owner is the most reliable decomposition heuristic in this domain (Finding Seams).
- Move the most volatile responsibility out first, not the largest. The one that changes weekly repays the risk of the move; the one that has not changed in three years does not (Extract Module).
- Take the invariant with the code. If "verified before mail" moves to notification, notification must be able to refuse — which means it takes a
VerifiedEmail, not a string (Where Invariants Live). - Leave
UserManagerin place as a thin delegating shell while callers migrate, and delete it when the last caller is gone (The Strangler Pattern).
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.
- Next change: "support login with a second factor". Under
UserManager, this touches the file 61 places import, so every one of them is in the blast radius of a mistake, and the full test suite is the only honest verification. Under the split it touches identity, whose dependents are the login path and nothing else. - Next change: "GDPR export must include profile and preferences but not credentials". Split: two modules produce their own export fragment and identity contributes nothing, which is enforced by the fact that it has no getter.
UserManager: someone writes a serializer over the whole object and a code review is the only thing standing between you and exporting hashes. - What stays expensive: anything that genuinely spans all four — a change to how a user is identified, say, from email to a tenant-scoped id. That is a change to the concept, and no split of the concept makes it cheap.
- Four modules with four interfaces cost more to navigate than one class, and a reader who wants "everything about users" now has four files open. That is a real and permanent cost.
- The migration is risk taken now for benefit later, on working code, and it can be the wrong trade for a codebase with eighteen months of life left (When Design Does Not Pay).
- Narrow types like
VerifiedEmailadd ceremony at every call site. They also make one class of bug impossible, and reasonable engineers weigh that differently (Units in Names and Types).
What can go wrong
- Four modules are created and all four import the same
Usermodel with every field on it, so the schema is still a shared global and nothing was actually separated (Shared-State Coupling). - The split is done by extraction alone: methods move, but the callers still fetch a whole user and pass it everywhere, so the interfaces are as wide as before (Exposing Too Much).
- Someone adds
UserFacadeto make migration easy, and it never goes away — now there are five units and one of them is aUserManagerwith a different name (Facade). - The mitigation fails when the delegating shell is convenient enough that new code keeps calling it. The fix is a lint rule that forbids new imports of the shell, not a wiki page.
- Notification depends on identity for the verified-address type, and identity depends on nothing. The direction is chosen so the security-critical module has the fewest reasons to be edited.
- All four depend on a persistence port they each declare; none depends on the ORM directly. That is the difference between a boundary and a folder (Dependency Inversion).
- The delegating shell depends on all four during migration. It is the only cycle-risk in the plan, which is why it is temporary and why the deletion date belongs in the ticket.
- "So every class should do one thing." That formulation is what produces classes with a single method and no meaning. The unit of decomposition is a reason to change, not a verb (Single Responsibility, Carefully).
- "Big classes are the problem." A 600-line module with one reason to change is fine. A 40-line class that changes for five reasons is the problem, and it will never show up in a size metric (Long Functions).
- "Split it into as many pieces as there are reasons." Reasons that always fire together are one reason. If security review and the vendor change always land in the same sprint for structural reasons, splitting them buys nothing (Over-Decomposition).
- "We can do this in one PR." Sixty-one call sites is a migration, and treating it as a refactor is how a two-week improvement becomes a three-month branch (Incremental Migration).
- god-object
- feature-envy
- shotgun-surgery
Testing it, and how it ages
- Identity gets tests that never construct a database, because it takes and returns values. If it cannot be tested that way, the credential logic and the storage logic are still fused (Testing as Design Feedback).
- Write characterization tests for
UserManager's current behaviour before moving anything — the point of the refactor is that behaviour does not change, and that is an assertion (Characterization Tests). - Test the invariant at its new owner: notification must reject an unverified address, as a test, not as a comment.
- Add one test that fails if a password hash appears in a log line. It is a crude test and it has caught this in real codebases more than once.
- Identity tends to stay small and stable for years, which is the signal that the boundary was drawn correctly: security-critical code that nobody has a reason to touch is the goal.
- Notification usually grows fastest and eventually splits again — channels, templates, suppression — and that second split is easy precisely because the first one happened.
- The design stops fitting when users stop being a single concept: B2B tenancy typically turns "user" into "person" plus "membership", and the four owners have to be re-cut against the new nouns (Ubiquitous Language).
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.
- GENERALThe question "what is this responsible for, and what makes it change" applies to a class, a Go package, a Haskell module, a Rust crate and a single function; only the noun changes, because the underlying cost — one unit, many independent triggers — is not a language property.
- PARADIGM-SPECIFICIn OO the unit is usually a class and the responsibility usually comes with the state it guards. In a functional codebase the state is elsewhere, so the same analysis lands on modules of functions plus the types they operate on, and the "who may mutate this" half of the question mostly disappears — which makes the analysis easier, not unnecessary.
- CONTESTEDThe strongest opposing view is that responsibility analysis is unfalsifiable: "reason to change" can be sliced at any granularity you like, so the technique mostly ratifies a split the author already wanted, and teams that apply it enthusiastically produce codebases with dozens of one-method classes and worse local reasoning than the god object they replaced. That criticism is largely right about how the idea is used; the defence is that the *external trigger* test ("who in the business asks for this change") is falsifiable in a way "one thing" is not.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — "can this be tested without constructing the world" is the cheapest available proxy for the responsibility question, and it is available before the refactor rather than after.