When you see a system with a rotted abstraction, it's usually not because the initial design was bad. It's because there wasn't enough information when it was designed.
An abstraction built on insufficient information is more expensive than no abstraction. It doesn't solve problems — it creates something that needs constant patching. And in this industry, the "see duplication, extract" instinct is so strong that few people pause to ask: do I know enough yet?
A Typical Pattern
Several modules with highly similar processing flows. Any engineer seeing 300-400 lines of structurally similar code has the same first reaction: "extract it." Define a unified interface, turn the flow into an engine, each module implements only its differences.
Given the current information, you can't find fault with this.
Then the business evolves.
First new requirement: certain modules need an extra processing step. Add a new method to the interface; modules that don't need it return empty implementations. Not pretty, but tolerable. A small crack.
Second: a validation step that only some modules care about. The interface grows again. Now there are seven or eight methods, each implementation only using six or seven. The crack is widening, but nobody feels the need to start over — it's just adding a method, what's the big deal.
Third: yet another divergence, relevant to only one type. At this point you look at the interface — it doesn't need more methods. It's already rotten.
A pile of "may or may not exist" methods. Every new feature either adds another optional method or builds a compatibility layer around it. Both have been done. The compatibility layers stack thicker and thicker, growing an ecosystem around the interface that's more complex than the interface itself.
This abstraction didn't reduce complexity. It created a structure that needs constant patching. And the cost of each patch is increasing — every new one is harder than the last. Eventually the interface becomes the system's biggest coupling point: all callers depend on it, but none actually need everything it offers.
This pattern appears in any language, any team, any business domain. It's not about engineer skill level — it's about timing. Nailing in an abstraction when information is insufficient means every subsequent business change stretches it. Stretch it enough times, and it deforms.
What Went Wrong
Two root causes.
First, mistaking incidental similarity for essential sameness.
Several modules look similar on the surface — because the business is young, requirements are simple, everyone uses only basic capabilities. As business complexity grows, they diverge in different directions. One cares about format conversion, another about compliance validation, another about copyright review. Their similarity was a coincidence of the moment, not an essential property.
Judging whether two things are "essentially the same" requires more than looking at what they currently look like. You need to look at what will drive them to change. If module A changes due to Client A's requirements, module B due to regulatory policy, module C due to data source format changes — their change drivers are completely different, and they shouldn't be squeezed into the same abstraction. A module is a unit of encapsulation for one dimension of change. Forcing things with different change dimensions into one unification couples all those dimensions together.
This has a more formal name in software design: the Common Closure Principle. Classes in a package should be closed to the same kind of change. The converse holds — if two things change for different reasons, they don't belong in the same package. Same for abstractions. If the multiple implementations of an interface each have different change drivers, the interface works against CCP.
Second, locking in the abstraction when information was insufficient.
With only one business scenario, you can't see change directions. You see "several things look the same." What you can't see is "they'll diverge in different directions."
The first use case tells you what steps exist. The second use case tells you which steps are truly common and which are variable. Designing an abstraction based on only the first use case isn't design — it's guessing. You're fitting a curve to one data point and expecting all future points to land on it. They probably won't.
What's the cost of guessing wrong? Not returning to the starting line. It's more expensive — you already have code depending on the abstraction, the interface is referenced in dozens of places, tests are written against it. Tearing it out means touching all of that. And if you don't tear it out, if you keep patching on top of it, every new requirement gets harder. Both paths are more expensive because you've welded the wrong structure into the system.
Wait for the Second Use Case
With at least two real use cases where you understand their commonalities and differences, an abstraction has solid ground to stand on.
First module: write it straightforwardly. Don't abstract for "there might be more later."
When the second module appears, study their differences carefully. Which processing steps are truly identical? Which only happen to look similar but have different business semantics? If most are shared with a few differing steps — introduce variation only at those steps. If the entire flow structure differs — they shouldn't share an abstraction.
When the third appears, you have data. If the first two changed in the same direction, abstraction is warranted. If they diverged, you know not to unify.
"At least two" isn't a rigid rule — it's an information threshold. Only one? You can't even see all the differences. When information is insufficient, not abstracting is the better design decision.
This is the same logic as TDD's "write only enough code to make the test pass": don't write code for requirements that haven't appeared yet. Except here, it's don't abstract for use cases that haven't appeared yet. YAGNI applies to structure, not just features. I wrote about why "for future extension" is usually a bad reason in a separate post.
Why "Write It Straight" Is a Sound Architectural Choice
Some will be uncomfortable — what about DRY? Code quality? Isn't not abstracting just copy-paste?
Sandi Metz said it in her RailsConf 2014 talk: duplication is far cheaper than the wrong abstraction.
Look at the cost structure.
Duplicated code is local. Changing A doesn't affect B, deleting A doesn't affect B. It smells in one file, doesn't spread. If you need to change three similar blocks — yes, three times. But each change is independent, testable, doesn't affect other callers. This is a controllable, linear cost.
A wrong abstraction is global. Changing an interface signature moves all implementations. Adding a compatibility method is visible to all callers. Once a wrong structural decision diffuses through the system, the repair cost isn't additive — it's multiplicative. Each dependent adds resistance. The abstraction that was introduced to save "changing three places" later becomes "evaluating all implementations and all callers for one small change."
I've seen too many systems like this: started as a clean, unified design, devolved into a pass-through layer that everything flows through but nothing gets from. All call chains pass through it, but it contributes zero to each chain. Had the abstraction never been introduced, later changes would have been much faster.
This isn't anti-abstraction — it's anti-using-guesses-instead-of-waiting when information is insufficient.
When You Can Abstract Early
You can, but the conditions are strict.
When you've built three or more similar systems in the same domain — your judgment of change directions isn't guessing, it's pattern recognition backed by experience.
When writing infrastructure or public libraries where the cost of backward-compatible API changes needs upfront evaluation. In these cases, "guess wrong, fix later" is itself expensive, so upfront design investment is justified.
When the change drivers are locked down by the domain itself. HTTP's request/response model, no matter how it evolves, won't become one-way notifications. A financial system's double-entry bookkeeping fundamentals won't be changed by business requirements. The domain has already done the abstraction design for you — you just implement it.
For most business code, none of these three conditions hold. You're facing a business still being explored, a domain you don't fully understand. Guessing abstraction shapes under these conditions isn't worth the risk.
A Question to Take With You
Next time you consider abstracting, ask yourself:
How many real use cases do I have right now? What are their commonalities and differences?
If you can't answer the "differences" part — because you only have one use case, because you haven't seen this code behave under different business pressures — don't abstract yet.
Delaying abstraction isn't procrastination. It's waiting for the information that makes the design decision sound. A good design decision, like a good abstraction, emerges naturally when information is sufficient — it isn't forced into existence when it isn't.
References
- Robert C. Martin, Common Closure Principle.
- Sandi Metz, All the Little Things, RailsConf 2014.