Notes · Dissecting Real Systems

growing

Utils Is Where Modularity Goes to Die

Module boundaries should follow the dependency graph, not your folder intuitions — and "optimal" can be defined precisely.

· · 13 min read

refactoring, modularity, build-systems, software-design, dissecting-systems

…organizations which design systems (in the broad sense used here) are constrained to produce designs which are copies of the communication structures of these organizations.

— Melvin E. Conway, "How Do Committees Invent?", Datamation (1968)

Cite this
APA
Mangalapilly, Y. J. (2026, June). Utils Is Where Modularity Goes to Die. Saṃhitā Notes. https://yesudeep.com/blog/utils-is-where-modularity-goes-to-die/
BibTeX
@online{mangalapilly2026utils,
          author  = {Yesudeep Jose Mangalapilly},
          title   = {Utils Is Where Modularity Goes to Die},
          journal = {Sa\d{m}hit\=a Notes},
          year    = {2026},
          month   = {June},
          url     = {https://yesudeep.com/blog/utils-is-where-modularity-goes-to-die/},
          urldate = {2026-07-02},
        }
Plain
Yesudeep Jose Mangalapilly. “Utils Is Where Modularity Goes to Die.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/utils-is-where-modularity-goes-to-die/.
RIS
TY  - ELEC
        AU  - Mangalapilly, Yesudeep Jose
        TI  - Utils Is Where Modularity Goes to Die
        T2  - Saṃhitā Notes
        PY  - 2026
        UR  - https://yesudeep.com/blog/utils-is-where-modularity-goes-to-die/
        Y2  - 2026-07-02
        ER  - 

A companion to the build-systems series: where it asked how build tools work, this asks how to write code so they work well. By the end you'll be able to define "optimal incremental build" as a precise objective function, state the common refactorings as deterministic algorithms, explain — formally — why the utils dumping ground is slow as well as ugly, and design against the forces that fill it in the first place.

Every codebase has the file. It's called utils.py, or helpers.js, or common.go, or Util.java. It started honest — one date-formatting function someone didn't know where else to put. Now it's fourteen hundred lines of datetime math, string parsing, number formatting, a retry decorator, and three functions that touch the DOM. You have written this file. You have grepped it at two in the morning.

It's easy to say utils is ugly — a junk drawer, low cohesion, the usual code-review tut-tutting. That's true and it's not the interesting part. The interesting part is that utils is slow, in a way you can measure and prove, and the proof tells you exactly how to fix it.

Cohesion is a build-time property, not an aesthetic one.

The model: a build is a graph, change is a distribution

From the survey we have the first half: a build is a directed graph G=(V,E)G = (V, E) whose nodes are units of compilation (files, modules) and whose edges uvu \to v mean "vv depends on uu" — change uu and vv may need rebuilding. A build system, given a changed node, rebuilds its dependents: everything reachable by following edges forward.

The second half is the part most discussions of modularity skip. Code doesn't change uniformly. Some modules churn constantly (business policy, UI copy); others are touched once a year (a hashing primitive, a parser for a frozen format). The rebuild cost is driven by the change probability p(m)p(m) of the edited module — and on a real project you don't have to guess p(m)p(m): it is the file's commit frequency in git log.

Dependents — the set of nodes reachable from mm by following dependency edges forward; everything that must be reconsidered when mm changes. The opposite direction (what mm relies on) is its dependencies. Learn more.

With those two pieces — the graph and the change distribution — "optimal" stops being a matter of taste.

What "optimal" means, precisely

Let D(m)D(m) be the set of dependents of module mm, and let c(m)c(m) be the cost of rebuilding mm (compile time, say). When an edit lands in mm, the build system pays to rebuild mm and everyone downstream of it. The expected rebuild cost of the whole codebase, per unit time, is the sum over every module of "how often it changes" times "what that change costs to rebuild":

Cost(G)=mp(m)d{m}D(m)c(d). \mathrm{Cost}(G) = \sum_{m} p(m) \cdot \!\!\sum_{d \,\in\, \{m\} \cup D(m)} \!\! c(d).

Reading the symbols: \sum says "add up, over every module mm"; {m}D(m)\{m\} \cup D(m) is "the module itself, plus everything downstream of it"; and |D(m)||D(m)|, when it appears below, is the count of that downstream set. The formula is the sentence above it, written compactly — nothing in it is beyond arithmetic. Learn more.

Read it in plain terms: a module hurts in proportion to how often it changes multiplied by how much sits downstream of it. A hashing-library node has a large downstream cone but a tiny pp — it's cheap, because it never changes. A business-rules node changes daily but sits at a leaf with no dependents — also cheap. The expensive nodes are the ones that are both frequently changed and widely depended on. That product, p(m)|D(m)|p(m) \cdot |D(m)|, is the rebuild tax of a module.

Think of each module as paying an insurance premium: how often it causes an accident, times how many neighbors are in the blast zone. A hazmat depot that never has incidents is cheap to insure; a fireworks stand out in an empty field is cheap too. utils is a fireworks stand in a crowded market — small fires, constantly, with everyone downwind.

Because the tax is a product, it can be drawn honestly: one factor is a height, the other a width, and the cost is an area. Here are those three characters to scale — drag either factor and watch what the area does:

The rebuild tax, drawn to scale. The rectangle's height is how often the module changes (p, edits per week), its width how many files depend on it (|D|); the shaded area is the tax p ⋅ |D|, counting each dependent rebuild as one unit. The presets are the essay's three characters — the hashing library (wide but calm), the business-rules leaf (busy but narrow), and utils (both at once). The slivers are cheap; only the square is expensive, because only the square grows in both factors.

An optimal set of module boundaries is one that minimizes Cost(G)\mathrm{Cost}(G) over the ways you could partition the same functions into modules — given that you can't change what the code does, only how it's grouped and who depends on whom. Refactoring for incremental builds is exactly the search for that minimum.

Why utils is provably slow

Now the pathology falls out of the formula. A utils module fuses functions that change for unrelated reasons — the datetime helper, the parser, the formatter. Two consequences, both bad for Cost(G)\mathrm{Cost}(G):

  1. Its change probability is the union of its parts. A module changes if any of its functions changes, so p(utils)p(datetime)+p(parse)+p(format)+p(\text{utils}) \approx p(\text{datetime}) + p(\text{parse}) + p(\text{format}) + \dots — approximately the sum, because each reason fires rarely. (Precisely: the chance that at least one part changes is 1i(1pi)1 - \prod_i (1 - p_i) — one minus the chance that every part stays quiet, where \prod multiplies those stay-quiet chances together. The plain sum is always at least that, and nearly equals it when each pip_i is small — the union bound.) Lumping raises pp.
  2. Everyone depends on all of it. Because file-granularity build systems track dependencies per file, every consumer of any function in utils is a dependent of the whole file. So D(utils)D(\text{utils}) is the union of the dependents of every function inside it — large.

Union bound — the chance that at least one of several rare things happens is at most the sum of their chances. Two 1-in-100 risks: the exact combined chance is 1.99%1.99\%; the lazy sum says 2%2\%. The sum only overcounts the overlap — both firing at once — which for rare events is tiny. Learn more.

A module with both a high pp (everything's reasons, summed) and a large DD (everyone, unioned) is precisely the high-rebuild-tax node the objective tells us to eliminate. Edit the one-line datetime helper and the parser, the formatter, and everything downstream of the entire file rebuilds — work that the change-reasons had nothing to do with.

Before. One utils node. pp is the sum of every reason its functions change; DD is everyone who uses any of them. A datetime edit rebuilds the parser's dependents too.

After. Split into datetime, parse, format. Each has one reason to change (small pp) and only its own dependents (DD). A datetime edit rebuilds only datetime's consumers.

Splitting utils by change-reason: editing one function no longer rebuilds the dependents of the unrelated ones.

Slide the split yourself — twenty-four consumers, one utils, and kk under your control:

The blast radius, drivable. At k = 1 any edit rebuilds all 24 consumers; slide the split and the same edit rebuilds only the edited module's consumers — the rebuild tax p ⋅ |D| falling by the factor you chose. "Edit a function" lands the next edit in another module.

The refactorings, as algorithms

"Improve cohesion" is advice; an algorithm is something you can run. Here are the moves that lower Cost(G)\mathrm{Cost}(G), each stated as a deterministic procedure over the graph.

1 — Split by change-reason

The core move. Don't group "all the helpers"; group "everything that changes for the same reason."

Split a module by change-reason.

INPUT:  a module M containing functions f1..fn
OUTPUT: a partition of M into cohesive sub-modules
1. Label each function f_i with its change-reason r(f_i) — the external
   force that would make it change (a locale rule, a wire format, a UI
   framework, a business policy). Estimate from git history: functions
   that change in the same commits share a reason.
2. Group the functions into classes by equal r(f_i).
3. Emit one module per class.

This lowers Cost(G)\mathrm{Cost}(G) exactly when the reasons' consumers differ: each new module carries the change probability of one reason, not their sum, and its dependent set shrinks to the consumers of that reason's functions. The condition is the point — if every consumer used every reason, each piece keeps the full dependent set, the sum reassembles unchanged, and the split bought nothing but module count. Splitting by change-reason pays where usage was already partitioned; the algorithm just makes the partition official.

2 — Invert the dependency at a volatile boundary

When a stable module is forced to depend on a volatile one, the volatility leaks upward: the stable module inherits the volatile module's pp. Break it with an interface.

Invert a volatile dependency.

INPUT:  edge stable → volatile (stable depends on volatile, p(volatile) high)
OUTPUT: the dependency reversed through an abstraction
1. Define an interface I capturing what `stable` needs from `volatile`.
2. Make `stable` depend on I (stable, rarely changes).
3. Make `volatile` implement I (the dependency now points volatile → I).
Result: editing `volatile` no longer rebuilds `stable` — only the
small implementation node that sits below it.

This is the dependency-inversion principle, derived from the cost function: it removes a high-pp node from the dependency cone of a widely-used stable one.

3 — Delete the barrel file

A barrelindex.ts that re-exports everything, or __init__.py that imports the world — silently re-fuses what you just split. Importing one symbol through the barrel makes you a dependent of the whole barrel, which depends on everything it re-exports. It reconstructs the utils fan-out you worked to remove.

Delete a barrel file.

INPUT:  a barrel B re-exporting modules m1..mk, and consumers importing via B
OUTPUT: consumers importing the specific modules directly
1. For each consumer C importing symbol s from B:
2.   find the module m_i that actually defines s;
3.   rewrite the import to come from m_i directly.
4. Delete B (or keep it for external API only, never for internal imports).
Result: each consumer depends on the one module it uses, not the union.

4 — Keep hubs stable (the leaf/hub asymmetry)

The cost function says the dangerous nodes are high p|D|p \cdot |D|. A hub — a module many things depend on — has a large |D||D| by definition, so it can only afford a tiny pp. Volatility belongs in leaves, where |D|=0|D| = 0 and a high pp costs nothing.

Triage by rebuild tax (a check, not a rewrite).

for each module m: tax(m) = p(m) * |D(m)|
sort modules by tax descending.
the top of that list is where to refactor first — split it by
change-reason (move 1) or push its volatility to leaves (move 2).

Why the drawer fills anyway

The four moves fix the utils you have. They don't explain why you'll have another one next year. Every codebase grows the drawer, which means the forces that fill it are stronger than the review culture guarding it — and a fix that doesn't name those forces is treating the symptom.

  • The name is a deferral. Nothing lands in utils because it belongs there; it lands at the moment its author decides that figuring out where it belongs isn't worth the interruption. The module is named for what the code isn't — not parsing, not UI, not the domain — and "not" is not a change-reason. In the objective's terms: utils is where functions with an unlabeled r(f)r(f) accumulate, and unlabeled reasons are exactly the ones that end up fused.
  • The friction is asymmetric. Appending to utils.py is one edit. A new module is a name to invent, a file, an import path, sometimes a build rule — and a review conversation about all four. Under deadline the cheap path wins every time; the drawer isn't a lapse of discipline, it's the equilibrium of that asymmetry.
  • Review pressure points the wrong way. The same reviewer who will spend three comments on a new module's name waves a helper into utils without one. Each wave-through teaches the next author where code goes to avoid questions.

It's 4:50 on a Friday and the fix needs a parse_retry_after helper. There are two homes for it: a new http-headers module — a name, a file, an import path, and a reviewer asking whether it shouldn't be http/headers — or line 1401 of utils.py, where nobody will say anything. You know which one ships. So did everyone before you; that is how the file reached line 1400.

The countermeasures are the forces, inverted — one each:

  1. Park it next to its only caller. A helper with one caller needs no home: it lives in the caller's file and inherits the caller's change-reason, and the build graph sees nothing new. Move it out on the second unrelated caller — a cousin of the rule of three — because two real callers are the first evidence of what the helper actually is: what they share names the module.
  2. Ban the names, not the helpers. utils, helpers, common, misc as a path segment is a confession that the change-reason is missing — so make the confession loud. A lint rule that rejects those path names turns a silent deferral into a visible decision at review time. The helper is welcome; the label "unlabeled" is not.
  3. Make the right thing as cheap as the wrong one. The drawer wins on friction, so lower the other side's: a one-command module scaffold, and a norm that a one-function module with an honest name is normal. The graph doesn't mind small files; it minds fused reasons (the over-splitting caveat below still stands — stop at cohesive, not atomic).
  4. If you keep an inbox, give it a zero policy. Where a landing zone genuinely helps, treat it like an inbox: things may land, nothing may live. Cap its size in lint and drain it by move 4's triage — highest p|D|p \cdot |D| first.

The common thread is a feeling, and the feeling is data. The discomfort you carry about utils is the deferred decision — the open loop of a naming you skipped. Hating the module is a defense mechanism in the strict sense, and it's load-bearing: the hate is what remembering a deferral feels like, and it protects you from the real failure mode, which is comfort. A team at peace with its junk drawer has stopped noticing that it defers, and from there the tax compounds quietly. The formula gives the instinct its number; the instinct is still what fires first.

How to find your own utils problem

You don't have to guess which module to fix. The cost function hands you the procedure: compute the rebuild tax of every file and look at the top.

  • p(m)p(m) — count commits touching the file over some window: git log --oneline -- path/to/file | wc -l.
  • |D(m)||D(m)| — the number of files that depend on it (your build system's dependency graph, or a static import scan).
  • Rank by the product. The high-tax, top-right quadrant — frequently changed and widely depended on — is your utils, whatever it's called. Split those by change-reason first; the long tail of low-tax modules can wait forever.
The churn × fan-out quadrant. Refactor the top-right first; the rest barely affects the expected rebuild cost.

The honest limits

The model is a lamp, not a law. Three caveats:

  • It needs estimated change probabilities, which means predicting the future from the past. git log is a good prior, not a guarantee; a module quiet for a year can suddenly become hot.
  • Over-splitting has its own cost the formula doesn't capture. A thousand one-function modules minimize rebuild tax and maximize navigation and ceremony pain. The objective bounds the build cost, not the human cost; stop splitting when modules are cohesive, not when they're atomic.
  • Granularity is capped by your toolchain. If your build system or language tracks dependencies per file (most do; C++ does so even for headers, as the survey noted), the finest boundary you can exploit is the file. Function-level incremental compilers move the floor down, but you can't refactor below what the tool can observe.

Lessons

  • A build is a graph; change is a probability distribution over its nodes. Module design is the act of shaping both.
  • Optimal has a definition: minimize Cost(G)=mp(m)d{m}D(m)c(d)\mathrm{Cost}(G) = \sum_m p(m) \cdot \sum_{d \in \{m\} \cup D(m)} c(d). A module's rebuild tax is p(m)|D(m)|p(m) \cdot |D(m)|.
  • utils is provably expensive: lumping unrelated functions sums their change probabilities and unions their dependents — high pp and large |D||D| at once, the worst corner of the objective.
  • The refactorings are deterministic algorithms: split by change-reason, invert volatile dependencies, kill barrel files, keep hubs stable.
  • To find what to fix, rank files by p(m)|D(m)|p(m) \cdot |D(m)| (git churn ×\times dependents) and start at the top.
  • The drawer fills because deferral is cheap and naming is not. Invert the friction: park a helper beside its only caller, move it out on the second unrelated caller, reject utils-family path names in lint, and give any landing zone a zero-resident policy. Distrust comfort with the drawer more than you distrust the drawer.

References

  1. M. Conway. “How Do Committees Invent?.” 1968. — the original Conway's Law paper
  2. Single-responsibility principle.” — and dependency inversion — the same ideas, stated without the build graph
  3. Cohesion.” — and coupling, for the classic vocabulary
  4. Rule of three.” — when a helper has earned a home of its own
  5. The Build Is Proportional to the Change.” — where the build-graph model comes from

How to cite

APA
Mangalapilly, Y. J. (2026, June). Utils Is Where Modularity Goes to Die. Saṃhitā Notes. https://yesudeep.com/blog/utils-is-where-modularity-goes-to-die/
BibTeX
@online{mangalapilly2026utils,
          author  = {Yesudeep Jose Mangalapilly},
          title   = {Utils Is Where Modularity Goes to Die},
          journal = {Sa\d{m}hit\=a Notes},
          year    = {2026},
          month   = {June},
          url     = {https://yesudeep.com/blog/utils-is-where-modularity-goes-to-die/},
          urldate = {2026-07-02},
        }
Plain
Yesudeep Jose Mangalapilly. “Utils Is Where Modularity Goes to Die.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/utils-is-where-modularity-goes-to-die/.
RIS
TY  - ELEC
        AU  - Mangalapilly, Yesudeep Jose
        TI  - Utils Is Where Modularity Goes to Die
        T2  - Saṃhitā Notes
        PY  - 2026
        UR  - https://yesudeep.com/blog/utils-is-where-modularity-goes-to-die/
        Y2  - 2026-07-02
        ER  - 

Annotations

Thank you — your note is held for review and will appear once approved.

Thank you — your note is published.

Please sign in below to leave a note.

Type to search · ↑↓ to move · ↵ to open · Esc to close