DecompositionGENERALPARADIGM-SPECIFICCONTESTED

Over-Decomposition

Ten files that must all be read together are worse than one file that need not be. Splitting has a cost, it is paid by every future reader, and nothing about it is free.

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

How do I tell a split that contains change from one that merely spreads it across more files?

The requirement

A new engineer asks why an order total is wrong. Answering requires opening eleven files, none longer than forty lines, each of which delegates to the next. Nobody on the team can explain the flow from memory.

The obvious build

Keep splitting. Each unit gets smaller, each is individually easy to understand, each is trivially unit-testable, and every metric a tool can compute — file length, function length, cyclomatic complexity — improves. Every step of this is locally correct, which is exactly why it goes so far.

Why it breaks

Understanding is not additive. Eleven files that each make one small decision require the reader to reconstruct the whole flow in their head, which is more expensive than reading one file that contains it (Local Reasoning).

How it breaks as requirements change
  • Understanding is not additive. Eleven files that each make one small decision require the reader to reconstruct the whole flow in their head, which is more expensive than reading one file that contains it (Local Reasoning).
  • The complexity did not go away; it moved into the *relationships* between units, where no tool measures it and no file shows it (Essential and Accidental Complexity).
  • The tests get worse, not better. Each tiny unit is tested with mocks of its neighbours, so the suite asserts that the wiring is what it is, and passes happily when the composition is wrong (Mocking).
  • Change gets more expensive in the common case: adding one field means editing the type, three mappers, two interfaces and the coordinator, and none of those edits made a decision (Shotgun Surgery).
  • Deleting becomes hard. Nobody can tell whether DiscountEligibilityPolicyResolver still has a caller, so it stays forever (Speculative Generality).
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
  • Every one of those eleven files was created deliberately, by engineers applying advice they had been given, and several were praised in review.
  • The tests pass, coverage is high, and no individual file looks wrong — which is why nobody has raised it.
  • Merging files back looks like regression to a reviewer who was taught that small classes are good (What Code Review Is For).
Invariants
  • An order total equals the sum of its lines plus tax minus discounts, and that has to be verifiable by reading, not only by testing.
  • Whatever the structure, a reader must be able to establish where a number came from in bounded time (Local Reasoning).

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • A unit earns its existence by *deciding* something. If its whole body forwards to another unit, it has no responsibility, only a name.
  • The coordinator owns the order of operations. If it owns nothing else, ask whether the order of operations is complicated enough to need a file.
  • The reader is a stakeholder with no representative in the review. Most over-decomposition is what happens when the author's convenience is the only cost anyone counts.
Boundaries
  • A boundary is worth having when something can change on one side without the other side knowing. A boundary that every change crosses is a cost with no service attached (Encapsulation Radius).
  • The practical test: remove the boundary and ask what became possible that should not be. If the answer is "nothing", it was not a boundary (Separation of Concerns).
  • Inlining is a legitimate and under-used design move. It is the exact inverse of extraction and carries the same burden of justification, not a higher one (Extract Function).

Eleven files, one addition

Below is the shape, compressed. No file is long, no name is bad, every unit is testable in isolation, and the calculation cannot be read anywhere. The reader has to hold the chain in their head and rebuild it, which is the cost that no metric on this code reports.

The question to ask of each unit is whether its name lets a reader stop. TaxCalculator does — you can believe it computes tax and move on. DiscountApplicationStrategyFactory does not, because knowing what it produces requires reading it, and then reading what it produced.

  • Ask of every unit: does its name let a reader skip its body? If not, the extraction added a hop and hid nothing (Information Hiding).
  • A factory with one product per case turns a readable if into a runtime lookup, and buys nothing until there is a second product that arrives from outside the code (Premature Abstraction).
  • Coverage on this code is excellent, which is worth sitting with: every unit is tested and the composition — where the £4 lives — is tested by nothing (Where a Test Must Be Real).
The chain a reader has to reconstruct
1// order-total-coordinator.ts
2class OrderTotalCoordinator {
3 constructor(
4 private lines: LineItemAggregator,
5 private discounts: DiscountApplicationStrategyFactory,
6 private tax: TaxCalculationService,
7 private rounding: RoundingPolicyResolver,
8 ) {}
9 total(o: Order) {
10 const sub = this.lines.aggregate(o) // -> LineItemAggregator
11 const strat = this.discounts.for(o) // -> factory -> 3 strategies
12 const disc = strat.apply(sub) // -> which one? runtime
13 const tax = this.tax.calculate(disc, o) // -> TaxRuleResolver -> RateProvider
14 return this.rounding.resolve(o).round(tax) // -> policy -> 2 impls
15 }
16}
17
18// To answer "why is this order £4 less?" you open:
19// coordinator, aggregator, factory, 3 strategies, tax service,
20// rule resolver, rate provider, rounding resolver, 2 policies.

Five of those units have exactly one implementation and exist because a factory was introduced "in case" there were more. The factory is the load-bearing part of the problem: it makes the runtime path unknowable from the text, so no amount of good naming lets a reader stop early (Factory).

The same calculation, merged

The merged version is longer as a single file and much shorter as a thing to understand. Every number is visible in one place, in order, and the two units that remain — tax rates and rounding — are the two that genuinely vary for external reasons.

This is not an argument that fewer files are better. It is that the three units removed had identical changesWhen lists and were therefore one unit; the two that stayed have different ones and earn their boundary.

Order total
Eleven units, five with one implementation
OrderTotalCoordinator
  LineItemAggregator
  DiscountApplicationStrategyFactory
    PercentageDiscountStrategy
    FixedAmountDiscountStrategy
    NoDiscountStrategy
  TaxCalculationService
    TaxRuleResolver
    TaxRateProvider
  RoundingPolicyResolver
    BankersRoundingPolicy
    HalfUpRoundingPolicy

// 11 files. Adding a loyalty discount touches 6 of them.
// Reading the calculation touches all 11.
One calculation, two real boundaries
// order/total.ts
export function orderTotal(o: Order, rates: TaxRates, r: Rounding): Money {
  const sub = o.lines.reduce((a, l) => a.plus(l.price.times(l.qty)), Money.zero)

  let disc = Money.zero                          // all discount rules, visible
  if (o.coupon?.kind === 'percent') disc = sub.times(o.coupon.rate)
  if (o.coupon?.kind === 'fixed')   disc = Money.min(o.coupon.amount, sub)

  const taxed = rates.applyTo(sub.minus(disc), o.country)   // varies by law
  return r.round(taxed)                                     // varies by market
}

// 3 files. The calculation reads top to bottom.

The three removed units changed for exactly one reason — a pricing rule change — so they were never independent, and separating them bought no containment while costing a factory, five interfaces and an unreadable runtime path. TaxRates and Rounding stayed because they change for genuinely different external reasons (tax law, market convention) and have more than one real implementation. The merged version is also honest about a cost: adding a fundamentally new *kind* of discount is now an edit to this function rather than a new class, which is worse if that happens often and better if, as here, it has happened twice in three years (The Rule of Three).

The device, run in reverse

The responsibility analysis is usually used to find units doing too much. It works just as well in the other direction, and the signal is unmistakable: a changesWhen list with one entry, identical to its siblings'.

When several units share one trigger, they are one unit. Keeping them apart does not distribute the change — it multiplies the number of places the same change has to be made (Shotgun Surgery).

responsibilitiesPercentageDiscountStrategy — one of three siblings behind a factoryA unit with too little to do
Knows
  • How to multiply a subtotal by a rate
Does
  • Multiplies a subtotal by a rate
Depends on
  • The DiscountStrategy interface it implements
  • Money
Changes when — 1 distinct reason
  • A discount rule changes

One trigger — and its two siblings have the same one, as does the factory that chooses between them, as does the interface all three implement. Five units, one reason to change: a discount rule change edits all of them, which is the precise inverse of what the split was supposed to buy. Merge them into one function and keep the interface only if a discount kind ever needs to arrive from outside this codebase — a plugin, a customer-configured rule, a rules engine. It has not in three years, so it should not exist in anticipation (Speculative Generality).

How to build it

Most important first.

  • Count the files a reader must open to answer a likely question. That number, not file length, is the thing to keep small (Local Reasoning).
  • Merge units that always change together. Two files with identical changesWhen lists are one unit wearing two names (Cohesion).
  • Delete pass-through units outright. A class whose methods forward to another class with the same method names is not an abstraction (What an Abstraction Actually Is).
  • Prefer a longer function with clearly named sections over five functions each called once, when the five exist only to make the first shorter (Long Functions).
  • Judge a split by whether it lets you *ignore* something. Extraction that lets a reader skip a chunk is a gain; extraction that forces them to chase it is a loss (Information Hiding).
  • When merging, do it in one reviewable commit per unit and keep behaviour identical, so the diff is boring and the argument stays about structure (What Refactoring Actually Is).

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: "add a loyalty discount". Over-split: a new policy class, a registration entry, a factory case, an interface method on three existing implementations that do not need it, plus the coordinator — six files, one decision. Cohesive: one branch in the discount function and one test.
  • Next change: "explain to finance why this order was charged £4 less". Over-split: eleven files and a debugger, because no single place shows the calculation. Cohesive: read one function. This is a cost that never appears in an estimate and is paid every week (Debuggability by Design).
  • What the over-split design does buy: adding an entirely new *kind* of discount that nobody anticipated is genuinely cheaper, because the extension point already exists. That is a real benefit and it should be weighed against the two costs above rather than dismissed (Extensibility).
What the recommended approach costs
  • Merging units gives up genuine extension points. If a new variant does arrive, you pay to re-extract, and this lesson's advice is wrong in exactly that branch.
  • Larger units are harder to assign to different people, so a merge trades reader comprehension for some parallel-work friction.
  • Judging by "files a reader must open" is a heuristic that penalises legitimate layering; it is better than counting lines and it is still a proxy.

What can go wrong

Failure modes
  • Every unit is individually testable and the system is untested, because nothing exercises the composition and the composition is where all the bugs are (Where a Test Must Be Real).
  • Renaming becomes a project: a concept spread across eleven files is renamed in eleven places, and half the time only ten of them get done.
  • The team concludes from the pain that decomposition itself is wrong and swings to a single 3,000-line service, which is the other failure and just as expensive (God Object).
  • The mitigation fails politically: merging files reads as "making things worse" to anyone whose review checklist counts lines, so the merge PR needs the change-cost argument attached to it or it will be blocked (Review as Design Feedback — and Why It Arrives Too Late).
Dependencies, and their direction
  • Each additional unit adds edges to the dependency graph, and the number of possible interactions grows faster than the number of units — which is why the eleventh file hurts more than the third.
  • Over-split codebases tend to acquire a dependency-injection container, because wiring eleven objects by hand is tedious. The container then hides the graph, so the one thing that could have shown the cost is now invisible (Service Locator).
  • Small units invite cycles: with enough of them, something eventually needs to call back upward and does (Dependency Cycles).
Misreads
  • "So write big files." The target is not size in either direction. It is how many places a reader must visit and how many places a change must touch (Single Responsibility, Carefully).
  • "Small functions are bad." Small functions with meaningful names that let a reader skip their bodies are excellent. Small functions that must all be read to understand anything are the problem, and the difference is whether the name lets you stop reading (Function Design).
  • "This means avoid interfaces." Interfaces with two or more real implementations do work. Interfaces with exactly one, added in anticipation, are the pure form of this failure (Premature Abstraction).
  • "Coverage is high, so the design is fine." Coverage measures which lines ran, not whether the composition is right, and over-split code reliably scores well on it (Testing as Design Feedback).
Smells this explains
  • speculative-generality
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Test the composition, not each hop. One test that a realistic order produces the right total is worth more than eleven tests that each unit forwards correctly (What a Unit Is).
  • Treat a test that consists mostly of mock setup as evidence about the design rather than as a test to improve (Test Doubles, Precisely).
  • Before merging units, write a test at the outer boundary so the merge is verifiably behaviour-preserving (Characterization Tests).
How this design ages
  • Over-decomposed code ages badly in a specific way: the units stop being merged even after their reason to exist has gone, because deleting a class always needs more justification than adding one.
  • The usual trigger for correction is a new joiner who cannot follow the flow. That report is data, not inexperience, and dismissing it is how the structure becomes permanent (Knowledge Sharing).
  • A healthy codebase oscillates: extract when a reason appears, inline when the reason goes. Codebases that only ever extract are on a one-way ratchet (The Refactoring Loop).

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 constraint is human working memory — a reader can hold a bounded number of relationships at once — so it applies to any language; what varies is how much a language lets a name promise, and therefore how often a reader can stop reading at the call site.
  • PARADIGM-SPECIFICIn OO the units are classes and each carries construction, wiring and an interface, so the per-unit cost is high and over-decomposition bites early. In a functional codebase a small named function is nearly free — no constructor, no injection, no lifetime — so the same number of units costs much less, and the threshold where this becomes a problem is considerably further out.
  • CONTESTEDThe strongest opposing view is that small units are what make a codebase navigable and safely changeable at scale: with good names and tooling that jumps to a definition in a keystroke, a reader rarely needs to read the body at all, and the alleged reading cost is a habit from a time before editors could follow a symbol. Teams working this way point to codebases with thousands of tiny, well-named units that they change confidently. The position here is narrower than it may sound — the objection is not to small units but to units whose bodies you *must* read, because the name did not let you stop.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Architecturemodular-monolith
Domains that do not exist yet
  • Testing & Reliability Engineering — a suite where every test is mostly mock setup is the loudest available signal of over-decomposition, and it shows up long before anyone struggles to read the flow.
  • Programming Languages & Runtime Internals — how much a name can promise (types, effects, visibility) sets how often a reader may stop at the call site, which is what decides where the split becomes a cost.