Abstractions are tricky. When used properly, they can help you maintain your code over time. However, a wrong abstraction can add a lot of unnecessary complexity to the project and trap you in an even worse maintenance hell.
So how to avoid the wrong abstraction? How to recognize when an existing abstraction isn’t right for us anymore? And what can we do about it at this point?
But first, what exactly is an abstraction?
I find formal dictionary definitions for this to be too… well, abstract. But this is a great definition: the process of taking away or removing characteristics from something in order to reduce it to a set of essential characteristics.
I like this definition best because it is applicable to both code and real life.
Abstractions allow us to think of big and complicated concepts as small simple things. If you were to draw a bathroom door sign, you’d probably draw a circle to symbol a person’s head, and some straight lines for the body, and probably add some triangle skirt for the lady symbol because it’s 1950. But the point is, while humans are a lot more complicated and detailed than a few lines and shapes, you won’t need to worry about stuff that happens in deeper layers of abstraction. Your drawing generalizes a person just fine while hiding away their inner biological and psychological mechanisms.
Abstractions, therefore, make it possible for us to communicate.
In the world of code, it’s essentially the same. Often we use built-in classes and prototype functions to be able to operate a complicated thing in a simple manner. We’re handed just the class “intention”, and not the actual implementation of it. Abstraction is one of the core concepts in computer science, and more specifically, one of the four key concepts of object-oriented programming.
There are few approaches for abstracting your code:
Don’t Repeat Yourself
“Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.”
Well, obviously, you’d say. And you wouldn’t be wrong. Code duplications are ugly and hard to maintain. Imagine fixing the very same bug in four or five different places. Ew. So this principle simply suggests that if you recognize a couple of components or more having some duplicated logic, go ahead and extract that logic so that the different parts of the application can share it.
I’m actually not sure who coined that one. WET is here to describe a code that is not, well, DRY. But Wikipedia has some suggestions regarding what this acronym stands for: “write everything twice”, “we enjoy typing” or “waste everyone’s time”.
Let’s go with the first one. It’s the least passive-aggressive. And here is a really good description, by Conlin Durbin:
You can ask yourself: “Haven’t I written this before?” two times, but never three.
As Durbin suggests, this shifts the focus towards your own judgment and is taking into account that different use cases may require different handling. It allows you to consider a little duplication. By the third time you stumble upon the same repeated piece of logic, stop and abstract it away.
The Wrong Abstraction
(I highly recommend clicking on the above quote and reading the entire article by Sandi Metz. It is really great.)
This is a very well-phrased principle. The wrong abstraction can and will become far more complex and harder to read or maintain than a code duplication, over time.
Everybody’s intentions are good, of course. You’re all just trying to write good code. At some point, you might recognize an unnecessary duplication; some components which share some functionality. You go ahead and do the right thing: extract the functionality and have them all lean and clean.
Then a new requirement shows up. They always do. One of the components now has to have a teeny tiny change in its behavior, but still pretty much follows the standard. With one or two small modifications that will be added to the abstraction, everything is still good. Right?
This is how it goes down:
Say you have a very simple dialog component that can receive a title, a text, and a button.
Very lean. Capable of receiving some data and present it.
Sometime later, a few new requirements come. You need to handle types. As the dialog may be of type warning/success/danger etc, you’d probably want to change its class accordingly so that the relevant style guide can kick in. No problem, we’ll add a “type” prop.
Also, you are asked to handle the case in which there is no title and the case in which there is no body text.
This will add us a few conditions, but well, what can you do.
Some more time passes. Then comes another new requirement: we also need to handle a whole bunch of buttons, instead of just one. So we’ll now pass an array of buttons with their entire data and map through it. Still not a big thing to ask of this component, I mean, right? It should support such a trivial case.
What if you also wanted to support an input? You could then use this dialog for a lot of other things, like login / create account for example. And what about an array of inputs? It could be a long form or a wizard of sorts.
I’m all for it. But let me just say that inputs will also need validations. Let’s say we pass the validations as another prop.
Our dialog is becoming more and more complex.
Just one last thing (for now): why don’t we also support a table format? Heck, let’s do it. Data might identify itself as a table. We’re not here to judge.
Blargh! This is pure hell. We wanted a lean container of data but we really had no control over what data is coming, and in what shape. Therefore we ended up with tons of conditional paths. But the worst part is that: the component doesn’t know its purpose anymore. What’s a dialog really? Isn’t everything a dialog?
You can see where this is going. As time passes, the abstraction is not really abstract anymore, but a huge bunch of different permutations depending on parameters and cases. After a few more iterations, the code is no longer readable. A year or two from now, the new employee in your team will not be able to understand what this abstraction actually does. Neither will you.
Then, things will break.
Back to duplication
The Dialog abstraction became useless. The somewhat shared logic wasn’t reason enough to force this one component to handle all data and cases at once. If we destructure it into duplications, we will easily see what is actually needed in all its possible permutations, and what isn’t. Then we can try and think of a new abstraction.
There is more than one way to approach this problem. At this point, your best shot is to re-duplicate, so you can see where you actually stand.
Where are you going with this?
I would like to suggest some helpful takeaways.
- If you find yourself in a situation like this, take a step back. Break the abstraction back into duplications. Now observe once again: which are the unique parameters of each component? Leave them there, and delete any logic that isn’t particularly needed in this component. Now you can write a new abstraction, based only on simple shared logic, with no conditionals and side effects.
- If you find yourself adding conditional scenarios and passing parameters to an abstraction — you probably got the abstraction wrong. For an abstraction to work fantastically in a large number of contexts, it has to identify its precise use case.
(on a side note: that can’t always be the case. The more complexity the abstraction is trying to hide, the bigger the chances of it to leak, which doesn’t necessarily mean it’s bad or useless. But we’re talking about simpler cases here).
- Same goes if have to think more than a minute about how to name your abstraction. A good abstraction has to declare exactly what it does. If you don’t know how to name it, maybe you’re not sure what it does. That’s never a good sign.
(Pro tip: try to avoid names like “util” or “manager”)
- Write code that solves your current problems. Don’t attempt to get away with a perfect silver bullet abstract code that will be written once and prevent all your future problems. If that were ever the case, none of us would have to work at all by now. The more common scenario is that your abstract joker will at some future point be used as a five-ton hammer on every problem. While we all try to lay good foundations, it’s really more like gardening than, say, building a house. Most of the decisions are reversible, and some will be revisited.
Therefore don’t be afraid of challenging even old abstractions that have been there for ages. It appears that the more complicated and confusing the code is, the more we tend to keep going with it, thinking it must have been the only way.
I highly recommend not to fall into this trap. Assume every piece of code made perfect sense when first written, but it might not still be the case. Allow yourself to question these things and you’d be surprised.