Problem Decomposition
Split a problem into responsibilities and give each one an explicit interface. Splitting it into folders named after technical types is not decomposition — it is filing.
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.
A feature is too big to hold in one head. How do I split it so the pieces are genuinely easier than the whole?
Add referral codes. A customer shares a code, a new signup enters it, and both accounts receive credit once the new customer's first invoice is actually paid.
Split it the way the codebase is already split. A ReferralController, a ReferralService, a ReferralRepository, a Referral model and a test file. Five pieces, one per folder, and everyone knows where each goes without discussion. That last part is a genuine benefit and it is why this is the default everywhere.
The five pieces are not five problems. They are one problem sliced across five files, so understanding the feature still requires reading all five — the split reduced file size and did not reduce what you have to hold in your head (Local Reasoning).
- The five pieces are not five problems. They are one problem sliced across five files, so understanding the feature still requires reading all five — the split reduced file size and did not reduce what you have to hold in your head (Local Reasoning).
- The pieces cannot be worked on independently. Both engineers touch the service, because the service is where all the actual decisions live and the other four files are plumbing.
- The interesting parts have no home. "Paid, and not later refunded" is a rule about time and money that belongs to nothing on that list, so it ends up as an
ifinside a webhook handler where nobody will look for it. - When the requirement changes — payouts become tiered, or the credit expires — the change lands in the service again. Every referral change touches the same file forever, which is the definition of a decomposition that did not decompose (Divergent Change).
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.
- The codebase already has
controllers/,services/,repositories/andmodels/, and everyone reaches for those four first. - Two engineers will work on this in parallel for a week, so the split has to let them work without editing the same files.
- Finance needs the credit to appear in the ledger, which is owned by another team and cannot be modified this quarter.
- A referral pays out exactly once, no matter how many times the payment webhook is delivered.
- Credit is never issued for an invoice that was refunded or charged back.
- A code cannot refer its own owner, and cannot be applied to an account that already used one.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Something owns *eligibility*: whether this code may be applied to this account at all. It knows about self-referral, prior use and code expiry, and nothing about money.
- Something owns *the referral lifecycle*: pending, qualified, paid, void. It knows which transitions are legal and refuses the rest (State Machines).
- Something owns *qualification*: what makes an invoice count. It knows about payment settlement and the refund window, and it is the only place that knows.
- Something owns *payout*: turning a qualified referral into a ledger entry, exactly once (Idempotency by Design).
- The HTTP handler owns none of it. It parses a request, calls one of the above, and formats an answer.
- The seams fall between the four responsibilities above, because each one has a different reason to change: eligibility changes when marketing changes the rules, qualification changes when finance changes what counts as paid, payout changes when the ledger changes.
- They do not fall between controller and service, because no requirement has ever changed "the controller" without changing the service underneath it.
- The boundary with the ledger is a hard one — another team owns it — so it gets an adapter that speaks our vocabulary rather than theirs (Anti-Corruption Layer).
- Each boundary needs an explicit interface, not just a file. A subproblem with no stated interface is not separated; it is merely elsewhere (Designing a Module Interface).
A decomposition is a list of decisions, not a list of files
The useful move is to write the requirement out as the decisions it contains, before opening an editor. Referral codes contain four: may this code be used here, does this invoice qualify, what is it worth, and how do we record it without paying twice. Nothing on that list is "a controller".
Each decision then tells you its own interface, because a decision is defined by what it needs to know. Eligibility needs an account and a code and returns a yes or a reason; it does not need a database connection, and the moment it asks for one, that is a signal that two decisions have been merged.
This is also the test for whether you have split anything at all. If two of your pieces cannot be described without mentioning each other, they are one piece with a file boundary through the middle.
- Every arrow points away from the transport layer. Nothing in the four pieces knows an HTTP request exists.
- The lifecycle sits between the two decisions and the effect, which is what makes "pay only once, only when qualified" expressible rather than merely intended.
- The ledger is behind an adapter because it is owned by someone else and will change on their schedule, not ours (Anti-Corruption Layer).
The same feature, split two ways
Both versions below are about the same length and both would pass review in most teams. The difference is not tidiness — it is that in the first, every future referral requirement lands in ReferralService, and in the second, each one has an address.
Notice what the second version makes *impossible*: there is no way to call payout without a Qualified value, and the only thing that produces one is the qualification step. The decomposition is enforced by the types rather than by a convention someone has to remember (Making Illegal States Unrepresentable).
// services/ReferralService.ts
class ReferralService {
async applyCode(accountId: string, code: string) { /* eligibility rules */ }
async onInvoicePaid(invoice: Invoice) {
const ref = await this.repo.findPending(invoice.accountId)
if (!ref) return
if (invoice.refundedAt) return // qualification, inline
if (ref.paidOutAt) return // idempotency, inline
await this.ledger.credit(/* ... */) // payout, inline
await this.repo.markPaid(ref.id)
}
}
// every future referral change edits this file// referrals/eligibility.ts export function checkEligible(a: Account, c: Code, prior: Use[]): Result<Pending> // referrals/qualification.ts export function qualifies(inv: Invoice, now: Instant, window: Days): Qualified | null // referrals/lifecycle.ts export function advance(r: Referral, e: ReferralEvent): Referral // guards live here // referrals/payout.ts export function payout(q: Qualified, key: IdempotencyKey): LedgerEntry // referrals/http.ts — parses, calls one of the above, formats
The four pieces have four different reasons to change — marketing rules, finance rules, lifecycle rules, ledger format — so four different future requirements land in four different files, and two engineers can work on the feature without touching the same one. The first version is not badly written; it simply has one address for four kinds of change, which is what makes every referral ticket a merge conflict.
Pricing the split honestly
It is easy to argue for a decomposition by picking the change it happens to contain. The argument is only worth something if you also name the change it does not help with, because that is the one that will arrive.
Here the split pays for changes to referral *rules* and does nothing for changes to what an account is. If the roadmap is full of the second kind, this decomposition is the wrong one and a different seam — around accounts — would be the one to draw.
Referrers earn 20% of the referee's first invoice, rising to 30% after five successful referrals, and credit expires 90 days after issue.
The flat amount turns out to be written in four places — the service, the email copy, the admin report and a seed fixture — and the expiry has no obvious home, so it becomes a nullable column plus an if at the top of onInvoicePaid. The rewritten service test is the expensive part: it mixed all four concerns, so changing one forces rewriting all of it.
Tiering is an input to payout; expiry is one new state and one guard on the lifecycle. Eligibility and qualification are not opened, which is not a coincidence — nothing about this requirement is about them.
How to build it
Most important first.
- Write the requirement as a sequence of decisions, not as a sequence of code. "May this code be used → is this invoice qualifying → how much → record it once" is four decisions, and the four decisions are the decomposition.
- For each decision, ask what it needs to know. Eligibility needs the account and the code; qualification needs the invoice and the refund window; payout needs an amount and an idempotency key. Those input lists *are* the interfaces (Designing a Module Interface).
- Check the reasons to change. If two of your pieces always change together, they are one piece. If one piece has two independent reasons to change, it is two (Single Responsibility, Carefully).
- Give the lifecycle an explicit type rather than a status string, so an illegal transition is a compile error rather than a support ticket (Making Illegal States Unrepresentable).
- Put the pieces next to each other — a
referrals/module containing all four — rather than scattering them across the technical folders (Package by Feature). - Stop at four. A fifth split, made because four felt untidy, adds a file and no separation (Over-Decomposition).
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: "referrers get 20% of the first invoice instead of a flat £10". Under the responsibility split this is one function in the payout piece and one test. Under the folder split it is the service, plus wherever the flat amount was also hardcoded for the email copy.
- Next change: "credit expires after 90 days". Responsibility split: a new transition on the lifecycle, one guard, one scheduled job. Folder split: a new column, a new
ifin the service, and a genuine question about whether the webhook path also has to know. - The change that is *not* cheaper: "referrals must work for team accounts as well as individuals". That cuts across all four pieces because it changes what an account is, and no decomposition of referrals contains it. Naming that honestly is the difference between a design argument and a sales pitch.
- Four named pieces are four things to learn before you can change anything. A single 300-line service is worse to change and genuinely faster to read once.
- The split costs an argument. Deciding where the seams go takes a conversation that "put it in the service" does not, and on a team that disagrees, that conversation can cost more than the design saves.
- Feature-shaped modules fight the framework in codebases whose tooling assumes layer folders — generators, conventions, autoloading. That friction is real and recurring (Decomposition by Folder).
What can go wrong
- The split is made on paper and not in the types: four "modules" that all import each other's internals, so nothing is actually contained (Dependency Cycles).
- Qualification quietly acquires a second job — it starts emitting the analytics event too, because it is the place that knows the invoice settled — and within a quarter it has the shape the service had.
- The decomposition is right and the interfaces leak: payout takes a raw database row, so the persistence schema is now part of the contract between two of your modules (Schema Leakage).
- The mitigation fails in a specific way: the team decomposes the *next* feature the same way without checking whether its decisions split the same, and copies a structure instead of doing the analysis.
- Payout depends on the ledger adapter; the adapter depends on the other team's API. The direction is one-way and the dependency is named in one file, so a ledger change has one blast site.
- Eligibility and qualification depend on nothing volatile — no HTTP, no clock singleton, no database. They take what they need as arguments, which is why they are the two pieces you can test in milliseconds (Volatile Dependencies).
- The HTTP layer depends on all four; nothing depends on the HTTP layer. That asymmetry is the whole reason to have it (Dependency Direction).
- "So layers are wrong." Layers are a fine *secondary* organisation inside a feature module. The mistake is making them the primary one, so that features have no home (Package by Layer).
- "Decompose until each piece is small." Size is not the target. A piece is right when it has one reason to change, and a 200-line function with one reason beats four 50-line functions with four (Long Functions).
- "This is just DDD." No aggregates, no repositories, no ubiquitous language required. This is asking what decisions the requirement contains — a much smaller claim, and one that holds in codebases where a rich domain model would not pay (When Domain-Driven Design Does Not Pay).
- "The pieces should be reusable." Reuse is not the goal and rarely arrives. Containment of change is the goal, and a piece used exactly once still pays for itself (Speculative Generality).
- divergent-change
- god-object
Testing it, and how it ages
- Eligibility and qualification get plain unit tests with no doubles at all, because they take values and return values. If they need a mock, the decomposition is not finished (Testing as Design Feedback).
- The lifecycle gets a transition test that asserts the forbidden transitions are unreachable, not just unused (Invalid Transitions).
- Payout gets one test that delivers the same webhook twice and asserts one ledger entry — the invariant, tested at the boundary that owns it.
- One integration test walks the whole feature. One, because its job is to prove the pieces are wired together, not to re-test their logic (Where a Test Must Be Real).
- The first two changes land inside single pieces, which is when the team stops arguing about whether the split was worth it.
- Around the fifth referral variant, eligibility grows its own internal structure — a rule list rather than a function. That is the piece earning its boundary, not the boundary failing.
- It stops being right if referrals stop being a feature and become a product with their own lifecycle, tiers and reporting. Then the four pieces become a module with an internal architecture of its own, and the seam moves up a level (Module Granularity).
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.
- GENERALThat a split is only useful when the pieces have different reasons to change follows from what makes a change expensive, so it holds in OO, functional and procedural codebases alike — only the unit differs: class, module, or file of functions.
- SCALE-SPECIFICAt one engineer and a two-day feature, a single well-named file is genuinely the better decomposition: the coordination benefit is zero and the navigation cost is real. The argument here starts to pay at roughly the point where two people touch the same feature in the same week.
- FRAMEWORK-SPECIFICRails, Django, NestJS and Spring all ship conventions that reward the layer split — generators, autoloading, discovery by folder. Going against them costs configuration and a permanent explanation to new joiners, which is a real cost that a language-agnostic argument tends to wave away.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same "split by reason to change" argument decides service boundaries, except that there the cost of a wrong split is a migration and an on-call rota rather than a rename.
- — Testing & Reliability Engineering — a decomposition is only trustworthy once you can show that each piece is exercised where it lives; testability is the fastest available feedback on whether the split is real.