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.

Written on July 15, 2026