Insight

When to Stop Abstracting

Early in my career I thought good code meant clever code: reusable components for everything, utility libraries for repeated patterns, abstractions layered on abstractions. Over time I've come to believe almost the opposite.

Early in my career, I thought good code was clever code. Reusable components for everything. Utility functions for any logic that appeared twice. Abstractions that anticipated future requirements and made the system flexible enough to handle whatever might come. I built things that were, by most definitions, well-engineered. And I made codebases harder to work with in ways I didn't fully understand at the time.

The problem with abstraction isn't abstraction itself. It's premature abstraction — building the general case before you understand the specific cases well enough to know what the general case actually is. When you abstract too early, you're not capturing a pattern. You're guessing at one. And the guess is usually wrong in ways that only become visible later, when the real requirements arrive and don't fit the shape you assumed.

The codebase I've found hardest to work with in my career wasn't the messy one. It was the over-engineered one. Every simple operation was wrapped in a helper. Every component was parameterised for flexibility it never needed. The original thinking was visible and clearly came from a good place — but the result was a system where doing anything simple required tracing through four layers of indirection to understand what was actually happening.

The discipline I try to practise now is something like this: build the specific thing first. Then build it again when you need it again. On the third time, if the pattern is really there — if the shape of the problem is genuinely the same, not just superficially similar — then abstract it. And make the abstraction as small as possible. Take out only what's genuinely shared. Leave the rest in place.

Building this portfolio site has been a good exercise in this. I've been tempted to create a Grid component, a PageIntro component, a set of layout utilities. Each time I've held back and just written the specific thing. And each time, I've been glad I did — because the 'similar' cases turned out to have enough differences that a shared abstraction would have needed so many configuration options it would have been more trouble than just writing the thing twice.

There are exceptions. A component that renders a card — consistent shape, consistent behaviour, used in many places — is worth abstracting early because the pattern is obvious and stable. A navigation item. A typography component. These are real abstractions with clear boundaries. The mistake is applying that same instinct to things that just look similar on the surface.

The broader point is that simplicity is a discipline, not a starting state. Code doesn't stay simple on its own. It takes active decisions — to not add the extra configuration option, to not extract the helper function, to not build the reusable component until you're sure you need it. Those decisions are harder than they sound, especially when you've been trained to see abstraction as the goal. But the codebases that are genuinely enjoyable to work in are almost always the ones where someone made those decisions consistently.