L. TongWriting
RSS

The cost of a clean abstraction

Every abstraction is a bet that the future resembles the present. Here is how to price that bet before you place it.

Curving stainless-steel facade, shadowed in black and electric blue, with a purple reflection.
Photo by miguel baranchuk on Unsplash.

There is a particular kind of code review comment I have written too many times, and received too many times, and it goes like this: could we pull this into a shared helper? It is almost always well meant. It is often wrong.

The instinct behind it is sound. Two pieces of code look alike, duplication is a known source of bugs, and consolidating them into one place means fixing things once instead of twice. Every programmer learns this early, usually right after being burned by a bug they fixed in three files and missed in a fourth.

But the instinct skips a step. It treats similarity as if it were the same thing as sameness, and those come apart faster than anyone expects.

Similarity is not sameness

Two functions that look identical today can be identical for entirely unrelated reasons. A formatPrice in the checkout flow and a formatPrice in the admin reporting view might both put a dollar sign in front of a number rounded to two places. Merge them and you have made a claim: that these will always agree.

Then the reporting team asks for accounting-style negatives in parentheses, and the checkout team asks for a currency switch, and your one clean helper grows two boolean flags that no caller understands.

The wrong abstraction is more expensive than the duplication it replaced, because duplication is obvious and a bad abstraction is load-bearing.

Sandi Metz, roughly, and she was right

What went wrong was not the merge. It was the missing question: are these the same thing, or do they merely look alike right now?

Pricing the bet

An abstraction is a bet that a set of call sites will change together. That gives you a way to price it. Ask what happens when one caller needs different behaviour, and how much work it takes to get it.

Change patternWhat it costs youVerdict
All callers change togetherOne edit, one testAbstract it
Callers change independentlyFlags, branches, couplingLeave it duplicated
Unclear whichWait for a third caseLeave it duplicated

That third row does most of the work in practice. Two examples is not a pattern, it is a coincidence with a witness. The third occurrence is where the real shape shows up, and it is usually not the shape you would have guessed from the first two.

A rule that has held up Duplicate freely until the third occurrence. Then abstract from all three at once, not from the first two plus a guess.

What to do instead of abstracting

The useful move, when you spot duplication you are not ready to consolidate, is to make it findable rather than to make it disappear.

  • Name them the same thing. Grep is a perfectly good index, and two functions called formatPrice in different modules are trivially discoverable without being coupled.
  • Cross-reference in a comment. Two lines in each file pointing at the other. No import, no shared module, no coupling.
  • Let it rot when it should. If they drift apart, the comment becomes wrong and you delete it. A stale comment costs a moment; a wrong abstraction costs a quarter.
One caution This works for logic. It does not work for constants that must agree — a tax rate duplicated in two files is a bug waiting for one of them to be updated. If divergence would be a defect rather than a feature, that is the signal to share.
// Also implemented in admin/reporting/format.ts — intentionally separate.
// These agreed as of Aug 2026 but serve different audiences, so let them drift.
export function formatPrice(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

That comment is doing real work. It tells the next person the duplication is a decision rather than an oversight, which is the difference between code they will respect and code they will “clean up” on a slow Friday.

The part nobody writes down

Undoing a bad abstraction is harder than making one, and not for technical reasons. Inlining a helper back into six call sites is mechanical. What stops people is that the helper has a name, and the name sounds like a concept, and arguing that a concept in your codebase is fake feels like arguing about philosophy rather than code.

So the asymmetry is real: abstracting is cheap and reversible in theory, expensive and sticky in practice. Price it accordingly. When in doubt, wait. The duplication will still be there next month, and by then you will know more.

This is the first in a loose series on decisions that look local but aren't. Next up: why the database schema is the hardest thing in the building to change.