← The ADLC library
Live debates · 68

Small modules beat clever ones, again

The old advice about narrow interfaces and boring code used to be defended on taste. It now has a price attached: cleverness is charged to every future change, because everything that has to be read to be understood has to be read again, by something that will not remember.

There is a genre of engineering advice that has been true for thirty years and has never once won an argument: keep modules small, keep interfaces narrow, prefer the boring construction over the clever one.

It never won because it was defended on taste. One engineer says the metaprogramming is elegant and removes duplication. Another says it is unreadable. Both are describing a preference, neither can price it, and the argument is settled by whoever is more senior or more tired.

That argument now has a number on one side of it. My position is that the small-and-boring school just acquired an economic case, because the cost of establishing enough context to change a piece of code safely is now paid repeatedly, per ticket, in a currency that appears on an invoice.

What cleverness actually costs

Cleverness is not a synonym for complexity. Some problems are hard and the code is complicated because the domain is. That is unavoidable and not what I am talking about.

Cleverness in the sense that matters here is a specific property: the code cannot be understood from the code you are looking at. Meaning lives somewhere else. A method dispatched by string. A base class three levels up that rewrites its subclasses’ behaviour. A decorator that changes the contract of everything below it. An implicit convention where the name of a file determines what gets registered.

For a human, this cost is paid once. Someone spends a bad afternoon working out how the registration works, and afterwards they know. The knowledge stays in their head for years and the cleverness becomes free to them, which is why the person who wrote it genuinely cannot see the problem.

For an agent, there is no afterwards. Every task starts from zero. The bad afternoon is re-lived on every ticket, except that instead of taking an afternoon it takes a large number of input tokens, and instead of ending in understanding it sometimes ends in a plausible guess.

Clever module

  • Behaviour determined elsewhere: base class, decorator, registry
  • To change it safely you must read four other files
  • Cannot be verified locally, only by running the whole thing
  • Cheap for the author, expensive for every subsequent reader

Small module

  • Behaviour is in the file you are looking at
  • The blast radius is stated by the interface
  • Verifiable on its own, so a wrong change fails immediately
  • Expensive once at design time, cheap on every read
The distinction is not lines of code. It is whether meaning is local. A 400-line module with everything in it is often cheaper to change than a 40-line one that inherits its behaviour from somewhere you have not found yet.

The unit of cost is the change, not the file

Here is the reframe that makes this budgetable.

Stop asking how big a module is and start asking: to make a safe one-line change here, how much has to be read first?

Call it the read cost of a change. It is the number that drives your input tokens, which on real workloads dominate the bill, and it is also the number that drives your error rate, because a partial read produces something internally coherent and incompatible with the rest of the system.

Notice that read cost and quality move together. This is unusual and it is why I think this argument holds. Most architecture debates are trade-offs where you buy one property with another. Here, the design that costs less to work in is also the design that produces fewer wrong changes, because both are consequences of the same property: you can tell what this code does by looking at it.

Change: add a field to the export.

Clever version export/registry.py read to find the handler core/base_exporter.py read for the template method plugins/csv_hooks.py read for the override config/exports.yaml read for the wiring -> then change 1 line

Small version export/csv_exporter.py read -> then change 1 line

Same one-line change. The difference is everything you had to establish before you were allowed to make it, and that establishment is now a per-ticket charge rather than a one-off cost to whoever happened to be on the team in 2022.

The duplication argument was already weaker than we admitted

The strongest defence of clever abstraction has always been duplication. Three near-identical implementations will drift, one of them will get a bug fix the others do not, and you will find out in production.

That argument is real, and the economics behind it have shifted, in two directions that are worth separating.

The cost of writing the third copy has gone to nearly nothing, which weakens the case for abstracting to avoid the typing. Nobody was ever really abstracting to avoid the typing, though, so that is a small effect.

The cost of the drift has not changed much. Three copies still diverge. What has changed is the cost of detecting the divergence, which is now much lower: finding all the places that implement a pattern, and checking them against each other, is a cheap operation that used to be an expensive one.

So the honest version is: abstract when the abstraction expresses something true about the domain, and stop abstracting purely to prevent divergence that you can now detect directly. That is a narrower rule than “duplication is fine”, and it is the one I would actually defend.

Where this breaks down

Small modules produce sprawl, and sprawl has its own read cost. Four hundred tiny files with narrow interfaces can be worse than forty medium ones, because now the difficulty is in finding the right file and understanding how they compose. Locality of behaviour and locality of code are not the same thing, and a rule of “smaller is better” applied without judgement produces a system where nothing is complicated and nothing is comprehensible.

This can be a licence to generate breadth. The thing agents are best at is producing more of something. Tell a team that small and explicit beats clever and abstract, and you may get twelve near-identical handlers where a modest shared function was correct. Genuine domain abstraction is still valuable and this argument gives people permission to skip the hard thinking that produces it.

Better tooling erodes the premise. If a tool can reliably resolve the dispatch chain, follow the decorator and construct an accurate local picture of a clever module, the read cost collapses and my whole argument goes with it. Call-graph-aware tooling is already better at this than it was a year ago. I would re-test the assumption annually rather than treat it as settled.

Rewriting a working system on this argument is almost always wrong. The read cost of a clever module is real and so is the risk of touching it. Nothing here justifies a refactor programme. It justifies a default for new code and a bias when you are already changing something, and anyone who quotes this article in a proposal to rebuild the export layer has misread it.

And the money is not usually the point. I write about platform economics, so I reach for cost, but for most teams the token difference between the clever and small versions of a module is not what will decide anything. The error rate is the real argument. I lead with the invoice because it is the number people can see, and I should be honest that it is the smaller half.

The takeaway

Keep it small, keep it local, keep the meaning in the file you are looking at. Same advice as always. The difference is that the cost of ignoring it used to be borne once, by whoever had to learn the system, and is now borne on every change, by a reader with no memory and no ability to ask.

Design for read cost. Ask what someone would have to establish before they could safely change one line, and treat that number as a property of the design rather than an inconvenience for whoever comes next.

If you take one thing into next week: take the last change an agent got wrong in a subtle way, and list every file that would have had to be read to get it right. That list is your read cost, and it is usually the actual defect.