You Might Not Need an Abstraction
Published 2026-02-05, About 14 minute read.
One of my all-time favorite Youtubers
MPJ used to often repeat
these words of wisdom:
The new engineer copies and pastes. The mid-level engineer makes
abstractions. The experienced engineer gets s*** done.
- MPJ (paraphrased)
The thrust of his saying was that there is often a common path in the course
of a software engineer's career. At first, they don't know what they are
doing, so they emulate. Then they move on to using abstractions to dry up
their code. Then they generally become pragmatic and don't worry about clever
abstractions.
I'd say in the past two years I've been visited with the same thought over and
over:
You might not need an abstraction. More precisely, you may
not need to make your own abstraction.
I've seen many engineers hampered in their efficacy because they become tied
to "beautiful" and "clean" code. But I've also seen many wonderful examples of
(in my estimation) great engineers and thinkers in our field that have
rejected abstractions or avoided rushing to create them, often producing "wet"
code. This "wet" code, though, is easier to work with and a pleasure to
maintain.
Raise your hand if you ever tried to abstract fetch 📎
If you didn't raise your hand, I'll bet you don't remember the times you did,
or you didn't realize at the time what you were doing! I've bumped into this
knee-jerk reaction so many times in my coding career and seen so many devs do
this that I would find it hard to believe it's not a rite of passage.
I actually bumped into this again in some recent work- it went something like
this:
-
"I know I'll be making these kinds of async requests often. Let me make
this a
primitive"
-
"Okay, this is nice, but now I need to configure the fetch headers"
-
"Cool. But I also want this thing to track its status with an enum."
-
"How neat and clean and encapsulated! Now I want to be able to make
callbacks for if the task fails, succeeds, or just resolves in general"
-
"But wait! I need to cover cases where I change the base url as well!"
"How elegant!" I think and pat myself on the back. I do this for a
while, only later realizing that I actually haven't made anything useful and
created a lot of redundant code. Here's why:
I just re-implemented fetch. Compare using fetch v.s. my new "Task":
-
Those status enums on my class? Unnecessary because the state is implicitly
known in handling promises. in other words, if I know if I'm in a
then or catch what the status is. With
try/catch I still have the same information.
- I still need to provide base url and configuration, just like fetch
-
Callbacks are probably worse than just handling the the result in the
promise
.then/.catch or the branches of the
try/catch when using await
-
In the end, I still have to run and handle a promise using
Task.start(). In my example, we still need to await it.
-
What is the benefit of creating this "task" instance, over just duplicating
the code as needed elsewhere? I think this is hard to defend.
The fundamental value of abstractions 📎
Okay, I'm going to get mathy here (after all, at heart I'm a mathematician.)
There is an information-theory-like way to describe the value of abstractions.
The Law of Abstractions!
For every piece of information or facet of an abstraction, there is a
corresponding explanatory facet in either the scope, the arguments/inputs,
or the context of the abstraction
It's kind of an law of entropy for abstractions. It might feel super obvious.
Here's a simple example:
The abstraction is a javascript function. It could be a class, a procedure, or
any other thing. But let's just stick to a function. There is no input (maybe
you could argue calling the function is a a facet), but there is a single
point of output, so that must come from somewhere. The answer is that it's
contained in the body of the abstraction. Here, it's just part of the
function.
With this small change, the abstraction now can change who the hello is
addressed to! There is basically two facets of the abstraction: the console
log string but also the name. The console log is part of the abstraction body.
The name is an explicit input to the abstraction. The "value" of this
abstraction is to not have to re-write the console log in your logic, but also
provide different names.
There are other things that can provide points of "input" that might seem like
it's breaking the theorem.
The purpose of this example is to show how abstraction entropy is different
from "functional" programming. I'm not claiming abstractions are functional
here. This function would generate new results all the time and is clearly not
functional. It does retain its entropy, though, in that the context provides
the informational inputs for the Date (through the javascript Date API that
the system uses- context) and the random number (through the pseudo-random
algorithm the JS Math library provides- again, the context.) So, for the two
console logs this function produces, we have clear inputs from context that
map to them.
Going one layer deeper... what if we have a callback?
My argument is that the function provided is an abstraction itself. So, the
inputs for the callback here are either abstraction body points of information
or contextual points of information. Any outputs from the outer abstraction
are informed by the inner callback which still follows the law. This means
that the abstraction provided to the abstraction needs to be analyzed!
The corollary 📎
And, if you can accept that, here's my corollary:
Corollary:
Abstractions are "better" when facets are accounted for more in the context
or body of the abstraction. In other words, abstractions do more for the
consumer with less inputs.
The more a consumer of your abstraction has to do manually, the less valuable
it is. The more a consumer needs to think about, the leakier your abstraction
is.
What does this have to do with our task abstraction above? I'd argue that
facets can be analyzed for the abstraction we made and compared to the facets
of just using fetch
Let's create a table with the facets of the Task abstraction, and see where
that information is accounted for in the abstraction body, arguments, or
context. If the corrolary is true, the abstraction should be better than just
using a fetch function if we find that the inputs are located in the context
or abstraction more. If we can basically minimize our inputs, this is a good
abstraction.
If we find we have the same inputs as fetch(), I would argue
we've made no improvement
| Facet |
Task Abstraction |
Fetch Abstraction |
| Domain to fetch from |
Consumer argument |
Consumer argument |
| Params |
Consumer argument |
Consumer argument |
| Fetch Configuration |
Consumer argument |
Consumer argument |
| Status |
Abstraction body |
Context of .then/.catch or branch of try/catch
|
| Actions depending on status |
Consumer argument |
Consumer places these in `` .then/.catch or branch of
try/catch
|
| Task reuse |
Consumer calling a method again (start()) |
Consumer running async function again |
The one thing you might argue is helpful is that you have runtime information
about the task status in memory on the class instance. However, I'd argue this
is not a distinct piece of information because you have the same information
from just using promises (and, in fact, there are workarounds to get the
status of a Promise, even though the status is not available programatically.)
We've just moved the point of information from the context (Promises in
Javascript) to the abstraction body (a property on the class.)
Similarly, the callbacks for certain actions on success, failure, and finally
are just shifted to a different part of the abstraction.
At the end of the day, we aren't getting any leverage here. At best, I wrote
an indirection with some mildly helpful information about its status. But
really, I argue, It's just a bad abstraction.
Are you saying don't abstract? 📎
No.
There are a ton of very good abstractions and APIs that we use all the time.
I'm not one to say we should all go back to assembly- that's foolishness.
I am saying that abstractions are hard to do well. When we
think we should create a base class, or create a mixin, or a decorator, or if
we think about writing a logging library... think about the value you are
actually trying to encapsulate for consumers. If you've ever had to work with
the wrong abstractions, you know it's a headache because it's actually
making work harder.
When designing APIs you have to consider carefully what details you can
encapsulate for the consumer. Those details can be "hidden" in the function,
class, or whatever you're building. If the consumer gets an extra point of
information out of it without having to include that point of information in
it, you've gained some leverage
Abstractions and when they're worth it has been talked about ad nauseum in
different forms. Dan Abramov has amazing examples in
Goodbye, Clean Code
and The WET Codebase.
Joel Spolsky has written the seminal work on abstraction boundaries in
The Law of Leaky Abstractions. There is also a well-known adage from Sandi Metz: "Duplication is better
than the wrong abstraction." Doing a google search on youtube or google will
give you more than enough videos and articles about this concept.
So what about fetch? 📎
The Fetch API in the browser (and recently node) is a good example of a good
abstraction.
Take a gander at what a browser request would look like before and after
fetch():
I would say that fetch() is better than an XHR request.
The one point of information that is assumed internally by fetch is the method
"GET", which can be overridden as an input if need be.
The other piece of information that is assumed is that execution is implied by
calling fetch. Fetch immediately returns a promise and executes the
asynchronous process. The XHR request needs an explicit method call, which I
would consider another facet that fetch provides.
Most importantly, fetch returns a
Promise. The Promise is a contextual point of information, like
Date, or the Math library. It also includes a lot of information on how to
assemble the promise result, information about the xhr request, and methods to
parse into JSON. To do the same with a Promise for an XHR request, you would
need to implement a lot of abstraction body work yourself:
So fetch provides some common defaults and organizes the xhr response with
niceties like status, ok, and json methods. The result is a promise, which
provides a whole set of machinery to handle async requests. You can pass this
promise around.
We get quite a bit out of this one function.
Summary 📎
At the end of the day, I think I'm suggesting that we avoid the tendency to
over engineer. Look at the class, or the base class, or the decorator and ask,
"What does this enable the consumer? What does this keep the consumer from
having to do?"
I've spent a lot of time since I learned about the
CAP Theorem trying to
see if there's a corresponding theorem for abstractions and use by consumers.
Admittedly, at times it feels a bit "hand wavey," but I think this is a lens
to analyze abstractions and give more concrete explanations than just saying
"this abstraction feels heavy" or "this abstraction is bad."
At any rate, I hope you enjoyed thinking about these things.
Find me on Bluesky or Mastodon. I also have an RSS feed here