DecompositionGENERALSCALE-SPECIFICFRAMEWORK-SPECIFIC

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.

The question

A feature is too big to hold in one head. How do I split it so the pieces are genuinely easier than the whole?

The requirement

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.

The obvious build

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.

Why it breaks

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

How it breaks as requirements change
  • 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 if inside 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).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The codebase already has controllers/, services/, repositories/ and models/, 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.
Invariants
  • 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.

Responsibilities
  • 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.
Boundaries
  • 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).
Referral codes, decomposed by decision
apply codeinvoice.paidpendingqualifiedonly from qualifiedvia adapterRequirement: referral creditHTTP handler / webhookMay this code be used?Does this invoice qualify?Referral lifecycleRecord credit, exactly onceLedger (other team)
UserLLMAgentToolDataDecisionHumanGuardrail

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

Referral credit
Split by technical type
// 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
Split by reason to change
// 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.

Referral payouts become percentage-based and tiered
The change

Referrers earn 20% of the referee's first invoice, rising to 30% after five successful referrals, and credit expires 90 days after issue.

ReferralService with everything inline
ReferralServiceReferralRepositoryInvoiceWebhookControllerReferralEmailAdminReferralReportschema migration
testsreferral_service_test (rewritten)webhook_testemail_testreport_test
6 modules · 4 test files

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.

Four pieces split by reason to change
referrals/payout.tsreferrals/lifecycle.tsschema migration
testspayout_testlifecycle_test
3 modules · 2 test files

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.

what it cost Four modules and two named types where there was one class: a new joiner has more to learn before the first change, and the indirection is real for anyone reading the flow once. The split also fixes the seams — a requirement that cuts across eligibility *and* payout (say, "referrers on the enterprise plan qualify differently") now touches two modules where the god-service touched one file. That is a genuine loss and it is the price of drawing the line anywhere at all.

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.

Cost of the next change
  • 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 if in 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.
What the recommended approach costs
  • 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

Failure modes
  • 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.
Dependencies, and their direction
  • 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).
Misreads
  • "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).
Smells this explains
  • divergent-change
  • god-object

Testing it, and how it ages

What to test, and at which boundary
  • 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).
How this design ages
  • 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.

Architecturemodular-monolith
Domains that do not exist yet
  • 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.