Module Granularity
How big should a module be? Big enough that a likely change fits inside it, small enough that one person can hold it. "As small as possible" is not an answer to either question.
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.
How do I know whether this module is the right size?
A code review asks for a 900-line module to be split. A different review, the same week, asks why a feature was implemented across eleven files of forty lines each. Both reviewers are experienced and neither can state the rule they are applying.
Smaller is better. Split until each module does one small thing, each file is under two hundred lines, and each function fits on a screen.
Small modules do not remove complexity; they move it into the relationships between modules, which are harder to see than code because they are not written down anywhere (Over-Decomposition).
- Small modules do not remove complexity; they move it into the relationships between modules, which are harder to see than code because they are not written down anywhere (Over-Decomposition).
- Eleven forty-line files means a reader must open eleven files and hold their interactions in their head, which is strictly more work than nine hundred lines with a clear internal order (Local Reasoning).
- Every split creates an interface, and every interface is a commitment. Splitting on a line count creates commitments at arbitrary places, which is worse than creating none (Premature Abstraction).
- A line-count rule is popular precisely because it needs no understanding of the domain, which is also exactly why it cannot answer the question being asked (Long Functions).
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 two reviewers have to agree on something checkable, because "it feels too big" cannot be argued with or learned from (Tone, Disagreement and Receiving Review).
- The team is twelve people, so any convention has to be teachable in a sentence.
- Splitting and merging both cost migrations of every caller, so the rule should not encourage churn (Incremental Migration).
- A module has one coherent reason to change. That is the property being sized for; line count is a symptom of it at best (Single Responsibility, Carefully).
- A change that a competent engineer would expect to be local is local.
- Somebody can read the module and say what it does without opening its dependencies (Local Reasoning).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- A module owns one coherent area of knowledge, including all the parts of it that change together (Cohesion).
- It owns a public surface small enough that its internals are genuinely internal (Designing a Module Interface).
- A reviewer arguing about size owns naming the reason to change they think is being conflated, or the change they think has been made non-local. Without one of those, the comment is a preference.
- The boundary belongs where a likely change stops, which is the same rule as everywhere else in this domain applied at a different grain (Finding Seams).
- It should also be somewhere a person can stand: if understanding either side requires the other, the boundary is in the wrong place regardless of the sizes it produces (Local Reasoning).
- The two constraints usually agree. When they conflict, prefer the one that keeps a likely change local, and document why the module is larger than it looks like it should be.
Signals in both directions
Both mistakes have symptoms, and neither symptom is a line count. Reading them as a pair is what stops the discussion collapsing into "smaller is safer", which is the direction teams drift when they have no evidence.
Every row below is checkable, and most are checkable from data you already have: merge history, CI timings and the diff in front of you.
| Signal | Points to | How to check it | What to do |
|---|---|---|---|
| A requirement change edits one small part and the rest is untouched, repeatedly | Too coarse | Which regions of the file appear in the last twenty commits | Split along the line the commits already draw (Divergent Change). |
| Several teams edit the same module every sprint | Too coarse, for organisational reasons rather than domain ones | Commit authorship over the last quarter | Split, or give it a narrow surface and one owner. The domain reasons may not have changed at all (Code Ownership). |
| A typical requirement touches eight files that always change together | Too fine | Co-change across the candidate modules | Merge them. This is the under-used direction (Breaking Cycles). |
| Understanding module A requires reading module B | Too fine, or the boundary is misplaced | Try to explain A to someone without mentioning B | Move the boundary or merge (Local Reasoning). |
| Tests for one module need another one set up | Too fine, or a boundary in the wrong place | Look at the test fixtures rather than the code | The test difficulty is the design feedback (Testing as Design Feedback). |
| The module has three unrelated reasons to change | Too coarse, regardless of its size | Write the reasons out as a list; more than one or two is the finding | Split by reason, starting with the most volatile one (Single Responsibility, Carefully). |
| You must mock parts of the module to test the module | Too coarse — it contains a boundary it has not admitted to | Look at what the doubles stand in for | That double is telling you where the seam is (Mocking). |
What each size actually costs
The scored comparison is worth making because the debate is normally conducted as though only one side had costs. Both do, and they are different in kind: coarse modules cost coordination and regression scope, fine modules cost navigation and interface commitments.
Read the middle row as the target rather than as a compromise. It is not "medium sized" — it is sized by a property, and the size that property produces varies enormously between a state machine and a report generator.
| Option | Simplicity | Flexibility | Testability | Operational | Migration cost | Note |
|---|---|---|---|---|---|---|
| One module per capability, large | Everything about the capability is in one place and a change rarely leaves it. Costs merge conflicts among concurrent editors, a full regression of unrelated concerns on every change, and a painful split later once it has several reasons to change (God Object). | |||||
| Sized by reason to change | The target. Each module has one coherent reason to change and a narrow public surface, so a likely change is local and future splits are cheap. Costs judgement rather than a rule, and therefore cannot be enforced by tooling or settled quickly in review. | |||||
| Many small modules, one idea each | Each file is trivially readable and trivially testable in isolation, which is exactly why the approach is persuasive in review. Costs a requirement spread across a dozen files, interface commitments at arbitrary places, and a system whose behaviour lives in relationships rather than in code (Over-Decomposition). |
caveat These scores describe a capability with genuine domain rules in a team of about a dozen. They shift substantially with language — a strongly-typed codebase supports larger modules because more can be ruled out from the types — and with team size, since coordination cost pushes the right size down as the number of concurrent editors rises. Nothing here is measured; the numbers summarise the arguments so the three can be compared at a glance, and the row that should decide your case is the one your merge history supports.
The same capability, split two ways
Both versions below have the same behaviour and the same tests. One was split by asking what changes together; the other by asking what could be given a separate name. Naming things separately is much easier, which is why the second one is more common.
The tell is not the file count. It is that in the second version the four files never appear in a commit alone — every rule change touches the validator, the calculator and the applier together, so the boundaries are crossed by every change they were supposed to contain.
src/pricing/ DiscountValidator.ts 42 lines DiscountCalculator.ts 58 lines DiscountApplier.ts 31 lines DiscountTypes.ts 24 lines // Last 20 commits touching any of them: // 18 touched all four // 2 touched three // Adding a discount type edits four files // and one interface.
src/pricing/
Discount.ts 155 lines
// types, rules, calculation, application
// exported: applyDiscounts(basket, codes)
BulkPricing.ts 90 lines
// exported: bulkPrice(sku, qty)
// Last 20 commits:
// Discount.ts touched 14 times, alone each time
// BulkPricing.ts touched 6, alone each time
// Adding a discount type edits one file.The four-file version drew boundaries where names existed rather than where change stops, so eighteen of the last twenty commits crossed all of them — the boundaries contained nothing and charged an interface each. The two-file version is drawn where the merge history already shows a line: discounts and bulk pricing genuinely change for different reasons and at different times. The cost is that Discount.ts at 155 lines will look too big to anyone applying a size rule, and it gives up the ability to test the calculation without the validation, which is occasionally what you want (What a Unit Is). If discount validation later becomes a separate concern — different owners, a different release cadence, an external rules service — the split is worth making then, against evidence rather than against a naming instinct (The Rule of Three).
How to build it
Most important first.
- Size by reasons to change, not by lines. One reason and eight hundred lines is fine; three reasons and eighty lines is not (Single Responsibility, Carefully).
- Use co-change as the evidence: parts of a module that never appear in the same commit are a candidate split, and separate modules that always do are a candidate merge (Change Amplification).
- Split when the module has acquired a second audience — a second team, a second lifecycle, a second rate of change — rather than when it has acquired a second thousand lines (Divergent Change).
- Merge when a boundary is crossed by most changes. That evidence is as valid as the evidence for splitting and is acted on far less often (Breaking Cycles).
- Keep the public surface small even when the module is large; a big module with four exported functions is much easier to live with than a small one with forty (Exposing Too Much).
- Prefer splitting along a line the domain already has a name for. A split that needs a made-up name is usually a split the domain does not have (Ubiquitous Language).
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.
- At the right granularity, a likely change is one module and a review one person can hold: the cost of the next change is bounded and does not grow with the system.
- Too coarse: every change to any of the module's several concerns costs a full regression of the others, and the module becomes a queue that several teams wait in.
- Too fine: a single requirement costs eleven files and an interface change, and the discovery cost — working out which of the eleven to edit — exceeds the edit itself.
- Changing your mind later costs a migration of every caller in both directions, which is why granularity is worth deciding from evidence rather than from a rule that will need revisiting.
- Larger modules mean more to read before making a change and more merge conflicts among people working in the same file; those are real costs and the honest case for splitting.
- Sizing by reasons to change requires domain understanding, so it cannot be enforced by a tool or delegated to a linter — unlike a line count, which is why line counts persist.
- Any granularity rule expressed simply enough to be teachable will be wrong at the edges, and the edges are where the arguments happen.
What can go wrong
- A module is split on a line count, and the two halves keep changing together — so the change cost rose by an interface and fell by nothing (Over-Decomposition).
- A module is left large past the point where it has three unrelated reasons to change, and becomes the file every pull request touches (God Object).
- The team adopts a numeric threshold, and engineers hit it by moving code into a helper file that the original imports, satisfying the rule and changing nothing.
- The mitigation fails on its own terms: co-change data is used to drive splits, but the data mostly reflects which area the team happened to be building last quarter, so modules are reorganised around finished work (Change Amplification).
- Every split adds edges: two modules where there was one, plus an interface, plus whatever wiring connects them (Fan-in and Fan-out).
- Fine-grained modules tend to produce wide, shallow dependency graphs where no single edge is informative and the whole graph is hard to read.
- Coarse modules concentrate fan-in, which makes them expensive to change and worth keeping stable (Stable Dependencies).
- "As small as possible." Small modules relocate complexity into relationships between modules, and relationships are less visible than code. The target is the size where a likely change is local and a person can hold the whole thing (Over-Decomposition).
- "Single responsibility means one function per class." SRP is about having one reason to change. A module with one reason to change and thirty functions satisfies it; eleven modules that all change for the same reason do not (Single Responsibility, Critically).
- "Line count is a proxy for complexity." It is a weak one. Nine hundred lines of a state machine with an obvious order is easier to change than three hundred lines spread over eleven files with implicit ordering (Long Functions).
- "Big modules are technical debt." Debt is a deliberate trade of future cost for present speed. A large, cohesive module with one reason to change is not debt in any sense — it is a module (What Technical Debt Actually Is).
- god-object
- divergent-change
- shotgun-surgery
Testing it, and how it ages
- A module at the right size can be tested through its public surface without doubles for its own internals. Needing to mock your own module is the clearest granularity signal there is (Mocking).
- If a test for one module routinely needs another one set up, the boundary between them is not where a change stops (Testing as Design Feedback).
- Test suites that must be run together are evidence the modules belong together, and that evidence is available for free in CI timings (Where a Test Must Be Real).
- Modules grow. The right size at the start of a capability is not the right size two years in, and the evidence for splitting accumulates gradually in merge history (Revisit Triggers).
- Splits are much easier when the module already had a narrow public surface, which is an argument for keeping the surface small long before the size becomes a question (Designing a Module Interface).
- Very fine-grained codebases tend to consolidate over time — usually informally, as engineers stop creating new files and start growing the existing ones — which is worth noticing as evidence rather than treating as decay.
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 the right size is set by what changes together and by what a person can hold, rather than by a line count, holds across languages — although what a person can hold varies with how much the language lets them rule out without leaving the file.
- LANGUAGE-SPECIFICA language with strong types, exhaustive matching and controlled mutation lets a reader hold a larger module, because much of it can be ruled out from the types alone. In a dynamic language the same nine hundred lines require reading more of the body and more of the callers, so the practical ceiling on module size is genuinely lower — the same code is the right size in one language and too big in another.
- SCALE-SPECIFICAt five engineers a large module is fine: everyone has read it and conflicts are rare. At fifty, the same module is a queue — several teams editing one file, competing for review, blocked on the same test suite — so the coordination cost pushes the right size down even when the reasons to change have not changed at all (Code Ownership).
- CONTESTEDThe strongest case for small modules by default: a hard size limit removes an argument nobody wins, gives junior engineers a rule they can apply without domain expertise, and errs toward the mistake that is easier to fix — merging two small modules is trivial, while splitting a grown one is a migration. Teams that impose a limit often report calmer reviews and no obvious harm. The counter is that the harm is real and simply not visible in reviews: it shows up as features spread across a dozen files and as interfaces committed to at arbitrary places, neither of which any single review can see.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — how much confidence a module-scoped test gives you depends on where the boundary sits, which makes granularity a test-strategy decision as much as a structural one.