Let the Code Tell You: Discovering Abstractions Instead of Inventing Them
There's a particular kind of confidence that shows up early in a project. Requirements are fresh, the domain feels manageable, and you can already see the elegant system you're about to build. So you start sketching. Interfaces here, base classes there, a service layer that'll handle all the things. It feels like craftsmanship.
Then reality shows up.
Two sprints in, the requirements have shifted, the abstraction you were so proud of is fighting you at every turn, and you're spending more time working around your own architecture than building features. Sound familiar?
Abstractions are one of the most powerful tools in a developer's toolkit — but only when they reflect something real. The discipline isn't in designing them upfront. It's in recognizing when they're ready to be named.
Why Premature Abstraction Feels So Right
Human brains are pattern-matching machines. Show a developer two similar functions and they'll immediately want to merge them. That instinct isn't wrong — duplication genuinely is a problem. But there's a difference between duplication that's converging toward a shared concept and duplication that just looks similar on the surface.
Consider a scenario where you're building an e-commerce app. You've got code that calculates shipping costs and code that calculates discount amounts. Both involve looping over line items and applying some math. A premature abstraction says: "These are both calculators — let's make a LineItemCalculator base class." A patient abstraction says: "Let me write both of these out fully before I decide if they actually share a concept worth naming."
The difference matters enormously. One path gives you a flexible system. The other gives you a straitjacket with a fancy name.
The Rule of Three (Used Correctly)
You've probably heard the old rule: don't abstract until you see something repeated three times. That's decent advice, but it's often applied too mechanically. Three instances of similar-looking code isn't the same as three instances of the same concept.
Before you reach for an abstraction, ask yourself:
- Would I describe these things the same way to a non-technical teammate? If you'd use different words to explain what each piece does, they probably don't belong in the same abstraction.
- Do they change for the same reasons? Code that changes together belongs together. Code that merely looks alike but responds to different pressures should stay separate.
- Am I naming a thing that exists in the domain, or naming a thing that exists in my implementation? Domain concepts make durable abstractions. Implementation conveniences make brittle ones.
If you can answer all three honestly and still think an abstraction is warranted, you're probably onto something real.
Concrete First, Always
The single most effective habit you can develop is writing the concrete version first — every time, without exception. No interfaces until you have at least one real implementation. No base classes until you have at least two concrete classes that genuinely share behavior. No service layers until you've felt the pain of not having one.
This isn't laziness. It's discipline.
When you write concrete code, the domain talks back to you. Edge cases surface. Naming gets harder in useful ways — if you can't name something clearly, that's a signal the concept isn't crisp yet. The friction you feel while writing concrete implementations is information. Abstract too early and you lose that feedback loop entirely.
A practical workflow that helps: start with the simplest possible implementation that makes your test pass. Then write the next feature the same way. When you're working on the third related feature and you find yourself copying significant logic, stop. Look at what you've written. The abstraction you need is probably already visible in the code — you just need to carve it out rather than design it from scratch.
Reading the Patterns Your Code Is Showing You
Concrete code, written honestly, tends to develop visible seams. These are the places where abstractions want to live. Learning to spot them is a skill that takes time, but a few patterns show up repeatedly:
The repeated conditional. If you're checking the same condition in three different places to decide which variant of something to use, that conditional is probably hiding a polymorphic concept. The branches are your abstraction, waiting to be named.
The data clump. Three or four variables that always travel together — always passed as a group, always modified together — are screaming to become a struct or a value object. The abstraction is already there in the data; it just hasn't been formalized.
The awkward parameter list. When a function takes six arguments and half of them are only relevant in certain scenarios, that function is probably doing two different jobs. The split point is your abstraction boundary.
The comment that explains intent. If you're writing comments like "// This handles the case where the user is a guest" above a block of code, that comment is often a class name waiting to happen.
None of these patterns demand immediate action. But they're worth flagging — mentally or with a // TODO: revisit this comment — so you can return when the pattern is more established.
When to Hold Off
Not every pattern needs to become an abstraction. Some duplication is genuinely cheaper to maintain than the indirection required to eliminate it. Before you extract anything, consider the cost of the abstraction itself:
- How many call sites will depend on this? A widely-used abstraction that turns out to be wrong is expensive to fix. A localized one is cheap.
- How stable is this part of the domain? Abstractions in volatile areas of your codebase tend to get refactored constantly. Sometimes it's more pragmatic to leave things flat until the domain settles.
- Does the abstraction make the code easier to read, or just shorter? Brevity isn't the goal. Clarity is. An abstraction that requires three levels of inheritance to understand is worse than a little duplication.
The goal isn't a codebase with the fewest lines. It's a codebase that communicates clearly and bends without breaking when requirements change.
Abstractions as a Living Process
Good abstractions aren't designed once and left alone. They're refined over time as your understanding of the domain deepens. The name you give something on day one might be wrong by month three — not because you made a mistake, but because you know more now.
Build the habit of revisiting your abstractions during refactoring sessions. Ask whether the names still fit. Ask whether the boundaries still make sense. Ask whether the abstraction is still earning its keep — because every layer of indirection has a maintenance cost, and that cost should be justified by the clarity or flexibility it provides.
The best codebases aren't the ones with the most sophisticated architecture. They're the ones where the architecture fits the problem so naturally that new developers can read it and immediately understand what's going on. That kind of fit doesn't come from designing abstractions in advance. It comes from listening to what the code is telling you — and being patient enough to wait until it's ready to speak.
Carve carefully. The shape is already in there.