DRY Rot
The most dangerous commit in a codebase is not the one that breaks the build. It’s the one that looks like good practice, passes review, keeps the tests green - and quietly seeds something that will not hurt for months. When it finally does hurt, there’s no single line to point at, because the damage was never in one line. It spread.
This is a story about one such commit. We went looking for a little repetition to tidy up, reached for the most textbook move there is - “don’t repeat yourself” - and did it in the single worst way available. Nothing went wrong. That was the problem. Let me walk through the whole thing: the innocent extraction, why it rots, and the rule we pulled out of the wreckage that tells you, up front, which abstractions are load-bearing and which are just rot with a name.
A confession first: this happened in a real project of mine - a terminal UI built on a component toolkit (think Swing, but for the terminal). Much of it predates the very rules I’m about to preach; it’s a working app and a field of anti-patterns I’m still weeding. No class in it is sacred. That’s exactly why it’s a good teacher.
Act 1: the innocent extraction
The UI has a sidebar with two panes: a list of file changes, and a diff. Each pane is built the same way - a scrollable text view, framed in a titled window:
view = Tuile::Component::TextView.new
window = Tuile::Component::Window.new('Changes')
window.content = view
window.scrollbar = true
Three lines of ceremony, and they appear twice (once for “Changes”, once for “Git diff”). A third pane elsewhere does the same. Any programmer’s reflex fires immediately: that’s duplication, kill it. So we did, the obvious way:
class TextPaneWindow < Tuile::Component::Window
def initialize(title, view)
super(title)
self.content = view
self.scrollbar = true
end
end
Now each site is one line: TextPaneWindow.new('Changes', view). The diff
shrank. The reviewer nodded - less duplication, a tidy little wrapper, textbook
DRY. Everything about this reads as good.
It is rot, and the spore has just landed.
Act 2: the long fuse
Here is the property that makes this worth a whole article: nothing goes wrong
now. TextPaneWindow works perfectly. It will keep working perfectly right up
until the day someone needs the second kind of thing - and that day is far
enough away that no one connects it back to this commit.
Say months later a pane needs a list instead of a text view. Or a text pane that shouldn’t scroll. Or one with a border toggle. Now you stand at a fork, and every branch looks locally reasonable:
Branch A - add another wrapper. ListPaneWindow. Then PlainPaneWindow for
the one that doesn’t scroll. The taxonomy has begun, and it grows as the
product of the options: content type × scroll × title × border × focus… A
class per useful combination is a combinatorial explosion that never closes.
Branch B - add a flag. Give TextPaneWindow a scrollbar: parameter, then a
border: parameter, then a content: that takes anything. Congratulations: you
have re-implemented Tuile::Component::Window, the class you were wrapping, with
a worse API and fewer features.
Branch C - bypass it. Just use a bare Window for the new case. Now the
codebase has two idioms for the same job - the wrapper and the raw component -
and no reader can tell which one they’re supposed to reach for, or why this pane
went one way and that pane the other.
There is no good branch. And notice: you never get an explosion, a crash, a red test, a moment where it’s obviously wrong and you stop to rethink. You get a slow mush, one entirely sensible commit at a time. The rot spreads through the structure the way real dry rot does - inside the beams, behind clean paint, following the grain into every adjacent timber - and you don’t see it until you put weight on it and something sags. By then you’re not sanding a blemish; you’re replacing joists.
That’s why this has to be taught. The failure mode is specifically designed to slip past every guardrail you have: it looks like a best practice going in, it passes the review, and it detonates far from the scene, disguised as a dozen small reasonable decisions.
Act 3: why it was a bad trade
Step back to the fork in the road - the original duplication versus the wrapper - and price them honestly.
Duplication’s cost is linear and visible. You can see the three repeated lines. They’re mildly annoying. If they ever become a genuine problem, the fix is cheap and local, and - crucially - you’ll have more information then about what they should become.
The wrong abstraction’s cost is non-linear and hidden. It compounds (every
new pane inherits the fork), it spreads (the taxonomy or the flags or the second
idiom), and it’s expensive to unwind precisely because code now depends on it.
You can’t just delete TextPaneWindow once six panes use it.
So the trade we made was: take a cheap, visible problem and swap it for an expensive, invisible one. That’s a bad trade even though it felt like progress - and it’s why the old wisdom holds: duplication is far cheaper than the wrong abstraction. A bit of repetition is a known, bounded cost you can pay later with better information. The wrong abstraction is a loan at compounding interest you took out blind.
Act 4: the diagnosis
Why is TextPaneWindow specifically the wrong abstraction, when wrapping a
component to make a new one is often exactly right? Because there are only two
places a reusable unit legitimately lives, and this one lives in neither.
The framework-generic level. Tuile::Component::Window is a unit of reuse
across every app that will ever use the toolkit. It’s reused by
configuration: you hand it any content, scroll or not, any title. It spans a
whole space of uses by design, and it earns its generality by being used
everywhere.
The domain level. A GitSummaryWindow - “the pane that shows git changes” -
is a unit of reuse within this app. It’s reused by intent: a reader reaches
for it because they want that concept. It means something in the problem domain.
TextPaneWindow is neither. It’s too specific to be the framework’s general
container (that already exists, and it’s better), and too generic to be a domain
concept (nobody, ever, reaches for “a text-pane-window” because they have a
text-pane-window-shaped problem). It sits in the dead zone between the two
legitimate levels. It is a unit of reuse of nothing.
That gives us the razor, and it’s sharper than “avoid duplication”:
A real abstraction hides a decision. A bad wrapper hides only keystrokes.
Tuile::Component::Window hides a decision - how to draw a frame, manage a
scrollbar, host content. GitSummaryWindow hides a decision - how git changes get rendered into
glyphs and colours. TextPaneWindow hides no decision at all: after you’ve used
it, you still have to know it’s a scrolling text window framed in a border. It
took nothing off your plate. It only saved you typing - and typing was never the
expensive part.
You’ve written the good kind a thousand times, so the razor isn’t “never wrap.”
Think of BufferedReader wrapping a Reader: one class around another - a
wrapper, structurally the very same move as TextPaneWindow - but it hides a
decision, namely how reads get buffered, so its caller stops thinking about
chunk sizes and syscalls entirely. Same shape, opposite verdict: one takes a real
problem off your plate, the other takes only typing off your plate. That is the
whole test.
Act 5: the rule that prevents it
The razor tells you when a wrapper is rot. But there’s a constructive rule underneath it that stops you from ever cutting the wrong boundary in the first place, and it reframes the whole activity:
Figure out the units of reuse first - then decide what they extend.
A unit of reuse is a thing a consumer reaches for by intent. “The git changes pane.” “A framed scrollable container.” “A wizard.” That’s the starting point of good design. The naive move - the one that got us here - runs backwards: it starts from “these lines look alike” and manufactures a unit to hold them. Duplication is a question, not an answer. It asks: “is there a unit hiding here?” Sometimes the answer is a domain component (extract it). Sometimes the answer is no, that’s a coincidence (leave it alone). It is almost never a generic wrapper in the dead zone.
Two clarifications keep this from being misread, and they’re where most people go wrong.
Reuse is not call-count. GitSummaryWindow might be instantiated exactly
once and still be the right unit; TextPaneWindow at three call sites is still
the wrong one. “Used more than once” is not the test - “reached for by intent”
is. This matters because “extract anything that appears twice” is the exact
instinct that grows rot.
A single caller can still deserve a class. This is the part that trips people, so here’s the cleanest example I know. Imagine a wizard component - prev and next buttons, a current-step index, showing exactly one pane at a time, disabling “next” on the last step. Your app has exactly one wizard. It is not library-grade; you could never ship it to strangers. A purist counts one caller and says squash it. Keep it anyway - and here’s the test that says why:
After you extract it, does the caller know less and corrupt less?
Extract the wizard and its host stops managing the step index, stops wiring
button-enabled state, cannot put the thing into an impossible state (two panes
visible, “next” past the end). The host now says wizard.add_page(x) and knows
nothing else. It knows less; it can break less. That is a real boundary, at
one caller.
Run the same test on TextPaneWindow: after extraction, the caller knows exactly
as much as before (it’s a scrolling framed text view) and can corrupt exactly as
much. The lines just moved to another file. Fake boundary.
And now the two things I earlier thought were separate justifications - “it’s reused” and “it’s cohesive” - collapse into one. They were never two axes. They’re two signals of the same thing: the class hides something worth hiding behind an interface that leaves its caller simpler. Many callers is the loud signal (why else would they all reach for it?). A single-caller black box like the wizard is the quiet signal. The disqualifier is identical in both cases - it hides only keystrokes. One razor, two signals.
Act 6: what we actually did
Here’s the punchline, and it’s the opposite of where we started. We set out to add a class to kill some duplication. Applying the rule honestly, we deleted four.
The two git panes had a separate “view” class each, wrapped forever in a window
that added nothing - fake boundaries, both. They folded into self-sufficient
GitSummaryWindow / GitDiffWindow domain components: one class per pane, each
hiding its own rendering decision, each reached for by intent. The sidebar that
used to hand-assemble the triple now just names the two windows it wants. And a
task-list pane that had been split the same way - a “view” class no one ever used
on its own, generated years ago under habits I hadn’t yet examined - collapsed
into a single window too.
Not one of those separated “view” classes passed the test. None was reached for
by intent; none made its caller know less; each hid only keystrokes from the
window sitting right next to it. So the “add a TextPaneWindow” reflex, followed
all the way down, turned into a subtraction. That’s what removing rot looks
like: the structure gets simpler, not more layered.
One guard for the road ahead, because it’s the trap on the way out. When one of those merged windows eventually grows too big, the fix is not to restore the view/window split we just deleted. That seam was always fake. You re-cut along a real boundary - you find a genuine mechanism inside (a parser, a renderer, a state machine) and extract that. “Too big” is solved by finding a black box, never by re-drawing the line that hid only keystrokes.
The moral
None of this is an argument against abstraction, or against DRY. It’s an argument against the reflex - the one that sees two similar lines and extracts a shape, before asking whether that shape is anything a consumer would ever reach for. The reflex feels like discipline. It reviews like discipline. And it is precisely how the rot gets in: the healthiest-looking commit in the history is where it started.
The rules are related. Component-Oriented Programming
is the discipline of building from real units - self-sufficient components you
reach for by intent - and Composition Over Inheritance
is the reminder that TextPaneWindow’s extra inheritance layer is pure cost
unless it buys you real meaning. This post is the third leg: a test for telling a
unit worth having from a name over nothing.
So before the next tidy little wrapper, run the razor. Does it hide a decision, or only keystrokes? Would anyone reach for it by intent? After it exists, does its caller know less? If the answers are no, the kindest thing you can do to your codebase is leave the duplication exactly where it is. The road to rot is paved with good intentions - and DRY, applied without that question, is how it gets in.