Understanding Premature vs. Unnecessary Abstraction: Lessons from the Codebase
As developers, we’re often taught to write reusable, maintainable code. However, in our enthusiasm to “do the right thing,” we sometimes over-engineer solutions, introducing abstractions too early or where they’re not needed at all. This can lead to code that’s harder to maintain, not easier.
In this post, I want to explore the difference between premature abstraction—where we anticipate future needs before they arise—and unnecessary abstraction—where the abstraction itself offers no tangible benefit, even in the long run. I’ll illustrate these concepts with real-world examples from my own work.
Premature abstraction
Let me know if this sounds familiar…
You’re working on a large feature and notice repeated code across smaller sub-problems. To avoid duplication, you create an abstraction. But as you continue, each new variation requires tweaking the abstraction. Repeat this a few times, and before long, the abstraction becomes complex, hard to follow, and brittle—trying to handle too many scenarios.
This is premature abstraction—introducing abstractions too early, before there’s a clear need. Instead of simplifying, it leads to overly complex, hard-to-maintain code. It’s often better to wait for clear patterns to emerge before abstracting.
Let me share an example of premature abstraction from my own experience.
At work, we had a React component called InputWithButtons
, designed to render an input field alongside a list of buttons passed as props. The idea was to create a reusable component for inputs with buttons, but this component was only used in one place—making it a premature abstraction right from the start.
Here’s a simplified version of the InputWithButtons
component:
type ButtonInfo = {
id: string;
icon: IconTypes;
onClick: () => void | Promise<void>;
tooltipInfo?: string;
}
type InputWithButtonsProps = {
value: string;
disabled: boolean;
buttons: ButtonInfo[];
}
export default function InputWithButtons(props: InputWithButtonsProps) {
return (
<div className="flex-start flex w-full gap-1">
<Input
disabled={props.disabled}
value={props.value}
/>
{props.buttons.map((currButton) => {
const Icon = SvgPickerNode({ icon: currButton.icon });
return (
<IconButton key={currButton.id} onClick={currButton.onClick}>
<Icon />
</IconButton>
);
})}
</div>
);
}
Later, I added a CopyButton
, which was always passed in the buttons array. To simplify things, I directly rendered the CopyButton
inside InputWithButtons
, removing it from the dynamic list of buttons. This effectively reduced the abstraction, but in doing so, the component lost its original generality and purpose.
export default function InputWithButtons(props: InputWithButtonsProps) {
return (
<div className="flex-start flex w-full gap-1">
<Input
disabled={props.disabled}
value={props.value}
/>
{/* Always render CopyButton instead of passing it dynamically */}
<CopyButton
value={props.value}
onClick={() => console.log('Copy action')}
/>
{props.buttons.map((currButton) => {
const Icon = SvgPickerNode({ icon: currButton.icon });
return (
<IconButton key={currButton.id} onClick={currButton.onClick}>
<Icon />
</IconButton>
);
})}
</div>
);
}
While this made the component simpler, it also rendered the abstraction pointless. By hardcoding the CopyButton
, the component no longer served its original purpose of dynamically rendering buttons—it had become specialized to one particular use case.
This is a good example of how premature abstractions can lead to unintended complexity or become irrelevant as the code evolves. It’s often better to wait until patterns emerge before introducing abstractions.
Unnecessary Abstraction
Unnecessary abstraction occurs when an abstraction never provides meaningful value to the code. It introduces complexity without offering any real benefits, regardless of when it was added. Even as the code evolves, this abstraction fails to improve the design or maintainability. It often leads to more convoluted code, making it harder to understand and work with, without any tangible gains in flexibility or reuse.
During a code review, I noticed a colleague had created a component to abstract a div with a className, used in two places in our codebase. His reasoning was that since it was used in more than one place, creating a reusable component seemed logical.
const Wrapper = ({ children }) => {
return <div className="custom-wrapper">{children}</div>;
};
// Used in two places
const ComponentA = () => (
<Wrapper>
<p>Content A</p>
</Wrapper>
);
const ComponentB = () => (
<Wrapper>
<p>Content B</p>
</Wrapper>
);
While the intent was to promote reusability, the component itself only renders a div with a className, without any additional logic or behavior. This is where the abstraction becomes unnecessary. In its current state, it doesn’t simplify the code or offer meaningful reuse—it just centralizes the className into one place.
In cases like this, abstraction doesn’t add value because:
• No logic or complexity is being abstracted—it’s just a div with a static className.
• Low likelihood of change: Since this is such a simple structure, there’s little chance that this wrapper will need to be modified or extended in the future.
• Readability: Instead of making the code clearer, the abstraction adds an extra layer of indirection, which can make understanding the code more difficult without providing any clear benefit.
Premature Abstraction vs. Unnecessary Abstraction
The difference between premature and unnecessary abstraction comes down to timing and need:
• Premature abstraction happens when you introduce an abstraction too early, before the codebase’s patterns or needs are clear. While it might not be useful at the time, it could eventually serve a purpose if those patterns emerge.
• Unnecessary abstraction, on the other hand, never provides meaningful value. It complicates the code without improving design or maintainability, regardless of timing.
In short, premature abstraction may have potential utility, but it’s introduced before a clear need arises. Unnecessary abstraction, however, has no utility even in the long run, adding complexity without benefit.