The Utility Dumping Ground
utils.ts is not a module, it is the absence of one. Its contents are the pieces of the domain nobody could find a home for, and it grows because it never says no.
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.
Why does every codebase grow a utils, common or shared module, and what is actually wrong with it?
Change how order reference numbers are formatted. The function lives in utils/format.ts, is imported by nine modules, and nobody knows which of them care about the format.
Some things do not belong anywhere in particular — a date formatter, a slug generator, a retry helper. Put them in utils so they are findable and not duplicated. It is one file, and it saves everyone reinventing them.
The premise is wrong for most of what ends up there. A reference-number format is not general-purpose; it is domain knowledge that was homeless, and calling the file utils made homelessness feel like a decision (Naming).
- The premise is wrong for most of what ends up there. A reference-number format is not general-purpose; it is domain knowledge that was homeless, and calling the file
utilsmade homelessness feel like a decision (Naming). - A module named for its lack of a subject can never reject a contribution. There is no argument against adding a function to
utils, so it grows monotonically and its reasons to change become the union of everyone's (Divergent Change). - Because everything imports it, it must sit at the bottom of the dependency graph — so any domain concept that lands there drags the domain to the bottom too, or gets flattened into primitives to avoid a cycle (Dependency Cycles).
- Change becomes unsafe in a specific way: nine importers, no stated contract, and no way to tell which of them depend on the exact output. Changing the format is a blind change (Change Amplification).
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.
- One consumer is an external partner integration where the format is part of a contract nobody wrote down.
- The file also contains date helpers, a retry wrapper and a currency rounding function, so its test file is unrelated to any one change.
- Everything imports it, so it sits at the bottom of the dependency graph and cannot import anything domain-shaped without creating a cycle.
- A reference number shown to a customer must match the one sent to the partner, and nothing currently enforces that they come from the same code.
- Rounding must be identical everywhere money is displayed or charged.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Order numbering is owned by the ordering module, including its format, its uniqueness rule and its external visibility.
- Money rounding is owned by the money type, because it is part of what the value means (Primitive Obsession).
- Retry policy is owned by whatever calls something unreliable, and it is a policy decision rather than a helper (Retries Are a Property of the Operation).
- What remains after those moves is usually a handful of language-level functions with no domain content at all, and that residue can have a real name.
- The rule is that a module's name must be able to reject code.
orders/can say "that is not about orders";utils/cannot say anything. - A dependency-free module at the bottom of the graph is legitimate only for things with no domain content. The moment domain vocabulary appears in it, the boundary is wrong (Stable Dependencies).
- Anything with external visibility — formats, ids, wire shapes — belongs in the module that owns the external contract, not in a shared file (Stable Boundaries).
A module that cannot say no
The defining property is not size, age or contents. It is that the module's name states no subject, so no contribution can be refused on grounds other than taste — and taste loses every argument in code review, correctly.
The fine case is narrow but real, and worth being precise about, because the useful residue is small and the domain knowledge around it is what does the damage.
looks like A file or package named utils, helpers, common, shared or misc. Imported by nearly everything. Its test file covers unrelated subjects. Its git history has contributors from every team. Functions inside it use domain nouns — formatOrderRef, isEligibleCustomer — that appear nowhere else in the file.
suggests Concepts the team has not named are being parked. Because the module is at the bottom of the dependency graph, anything domain-shaped that lands there has been stripped of its rules to avoid a cycle, and its contract with nine importers is undocumented.
fix Classify each function by which part of the business would ask for it to change, move each to that module and delete it in the same commit, then give the domain-free residue a real name and stop adding to it.
chunk, groupBy, clamp, a stable sortBy. This is genuinely not domain knowledge: nothing your business does can change what chunk should return, no importer has an undeclared expectation of it, and it will be deleted when the language ships its own. Give it a subject-bearing name (collections, not utils) so that the *next* contribution can be refused, and keep the rule that nothing entering it may mention a domain noun (Package Design).1utils/index.ts2 formatOrderRef(id) -> domain: orders own their format,3 and a partner depends on it4 roundMoney(n) -> domain: this is part of what Money5 means, not a rounding helper6 withRetry(fn, times) -> policy: whoever calls the unreliable7 thing owns how it retries8 chunk(xs, n) -> residue: genuinely general, no9 domain content, safe to keepThree of the four have owners already; only the fourth is what the file claims to be. That ratio is typical, and it is why "it is only helpers" is almost never true.
Sorting the contents by who would ask for a change
The classification question is deliberately not "is this generic?" — everything looks generic once it is written as a function of its arguments. The question is who, in the business, could ask for this to behave differently. That has a concrete answer for domain code and no answer at all for the residue.
| What is in the file | Who could ask for it to change | Where it belongs | What the move buys |
|---|---|---|---|
formatOrderRef | Operations, or the partner's integration team | The ordering module, behind a stated contract | The change has reviewers who know whether the partner cares |
roundMoney | Finance, or a new currency | The Money value type (Primitive Obsession) | Rounding cannot differ between display and charge |
isEligibleCustomer | Marketing, monthly | The customer or pricing module | A weekly-changing rule stops sharing a file with stable code (Divergent Change) |
withRetry | Whoever owns the flaky dependency | The adapter that calls it (Retries Are a Property of the Operation) | Retry policy sits next to the timeout and the error taxonomy it depends on |
parseCsvLine | Whoever owns the import format | The importer module | The format's quirks are documented where the format lives |
chunk, clamp | Nobody — the language, eventually | A named collections module | Almost nothing, and that is the point: this is the part that was always fine |
Dismantling it without a wide, risky commit
The reason these files survive is that fixing them looks like a large diff with no feature attached. The loop below spreads that cost across ordinary work and keeps every step independently revertible, which is what makes it something a team will actually finish.
- 1Freeze the inflow
Agree that nothing new is added to the file. New helpers go to a module with a subject, even if that module is created for one function.
fails by Being a rule with no enforcement, so it holds for three weeks; a lint rule on the path is worth more than an agreement (What to Automate Out of Review).
- 2Classify
For each function, name who could ask for it to change. Write the answer next to it in a scratch file, not in the code.
fails by Asking "is this generic" instead, which classifies everything as residue.
- 3Write the contract tests
For anything with external visibility, capture the current behaviour as an explicit expectation before moving it.
fails by Skipping the ones that look trivial — formats are exactly the trivial-looking thing an external party depends on (Characterization Tests).
- 4Move one function per change
Move it to its owner, update importers, delete the original. Ship it with whatever feature work is already in that area.
fails by Leaving a re-export behind, which preserves the coupling and makes the file look emptier than it is.
- 5Duplicate rather than re-share
When two unrelated modules want the same three lines, let both have them.
fails by A reviewer citing DRY. The answer is that these are two pieces of knowledge that happen to look alike (Duplicate Knowledge).
- 6Name the residue
Rename what is left to describe its subject, and delete the old path entirely.
fails by Renaming to
coreorcommon, which has all the same properties and sounds deliberate (The Common Module).
Nothing here needs a project. Each step is small enough to ride along with feature work, which is the only budget this kind of change reliably gets.
How to build it
Most important first.
- Read the file and classify each function by which part of the business would ask for it to change. That question sorts almost everything in one pass.
- Move each item to the module whose reason to change it shares, and delete it from the dumping ground in the same commit (Move Responsibility).
- For the residue with genuinely no domain content, give it a name that describes the subject —
text,dates,collections— so that the next contribution can be refused on grounds other than taste. - Prefer duplicating a three-line helper over sharing it across two unrelated modules. The duplication costs three lines; the shared file costs a coupling between reasons to change (DRY: Knowledge, Not Lines).
- Add nothing to a
utilsfile while it exists. The dismantling only converges if the inflow stops first.
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.
- Before: changing the reference format costs reading nine importers to find out which of them care, an unbounded risk that one of them is the partner integration, and a test suite that tells you nothing because it tests the function rather than any consumer's expectation.
- Before, compounding: every module that imports the dumping ground for one function inherits a reason to be recompiled, retested and reviewed when any other function in it changes.
- After: changing the reference format costs one edit inside the ordering module, whose tests state the external contract explicitly, and whose reviewers are the people who know whether the partner cares.
- What did not get cheaper: a genuinely cross-cutting change — every date in the system rendering in a new timezone — still touches every module that formats a date, and moving the helpers made that slightly worse by scattering them.
- Dismantling means touching every importer, which is a wide, low-value-looking change that is hard to justify against feature work (Change Size: Why Small Changes Are Safer, and When They Are Not).
- Moving a helper into a domain module makes it unavailable to another module that legitimately needed it, and the honest answer is often to duplicate it — which reviewers will flag as a violation of a rule they half-remember (DRY: Knowledge, Not Lines).
- Some genuinely shared, dependency-free code exists, and being too doctrinaire produces three copies of a correct base64 decoder, which is a real cost with a real defect risk.
What can go wrong
- It is renamed rather than dismantled —
common/,shared/,core/,lib/— and the same file continues under a name that sounds architectural (The Common Module). - It is split by type into
stringUtils,dateUtilsandobjectUtils, which sorts the residue nicely and leaves every piece of domain knowledge exactly where it was. - The domain functions are moved and the imports are left pointing at a re-export, so the dumping ground survives as an index and nothing about the coupling changed.
- The mitigation fails: "a module's name must be able to reject code" is a rule people apply to new files and not to the module they are already in, so the residue module slowly becomes a dumping ground with a better name.
- Every module depends on the dumping ground, which makes it the single most depended-upon thing in the codebase and the one with the least defined contract (Afferent and Efferent Coupling).
- It cannot depend on anything domain-shaped without a cycle, which is why domain concepts arriving there get degraded into primitives and lose their rules.
- After dismantling, the dependencies point from consumers to owners, which is more edges in the graph and fewer edges into a single node.
- "So never share code." The argument is about *unnamed* sharing. A module with a subject, a contract and an owner can be shared by fifty consumers and be entirely healthy (Shared Libraries).
- "Rename it to
coreand add a review rule." The name is the symptom. Without the classification pass, the same functions live on under a name that is harder to criticise (The Common Module). - "It is only a problem when it gets big." It is a problem the moment domain knowledge lands there, which is usually the second or third function, long before size is noticeable.
- "A monorepo package boundary fixes it." A package named
sharedhas all the same properties plus a version number (Monorepo vs Polyrepo).
- utility-dumping-ground
- divergent-change
- shotgun-surgery
Testing it, and how it ages
- Before moving anything, write the test that states the contract nobody wrote down: this order number format is visible to the partner and must not change (Characterization Tests).
- After the move, each function should be tested through the module that owns it, in domain terms, rather than as an isolated helper.
- A residue module of language-level functions should be tested exhaustively and cheaply — those tests are the only thing keeping it honest.
- Dumping grounds appear in week three of every codebase, when someone needs a function twice and there is no module yet. That is a legitimate moment; the failure is that it is never revisited.
- They grow fastest in the areas where the domain model is weakest, so the contents are a fairly reliable map of the concepts the team has not named yet.
- After dismantling, the shape returns unless something changes about how new code gets a home. The durable fix is that every new module gets a subject before it gets contents (Package by Feature).
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 mechanism is naming, not language: a module named for what it is not cannot refuse a contribution. It shows up as
utils.py,helpers.rb,Util.java,pkg/utilandcommon/with identical dynamics; only the file extension differs. - SCALE-SPECIFICOn a solo project the dumping ground is a filing convenience and costs almost nothing, because the author remembers what is in it and who cares. At twenty engineers it is the highest-fan-in module with the least-defined contract, and that combination is where blind changes come from.
- CONTESTEDThe strongest opposing case is pragmatic and worth stating: a single well-tested
utilsfile with fifteen small pure functions is easy to find, easy to review, has no cycles and has never caused an incident in many real codebases — whereas dismantling it produces a wide refactor, some duplication, and an argument every time someone needs a function twice. That is true of the residue and false of the domain knowledge, and the two are almost always in the same file.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same "shared thing with no owner" dynamic appears between services as a shared database or a common library, where the fix is ownership and a published contract rather than tidier files.