Abstractions, and why interfaces
June 01, 2023
As I am going through the career ladder, I’m finding myself having discussions on what it means to be a senior engineer. While getting different roles, to reflect my “knowledge” within certain domains or companies. Awww, you think you know something? That is the problem, as I am realizing how little I know. And the topic of this writing is no different. What constitutes a good abstraction in your code base?
In my experience polarity of this is always in extremes. Either everything or nothing. And as we gather more and more experience, our attitudes towards this tend to fall somewhere along this plane. And change over time. Which is normal, and to be honest, expected. I hope everyone works, or at least knows, with people that can challenge their way of thinking, on a regular basis. Just because something is an accepted way of working on a project, doesn’t make it a rule for the rest of the world. Even for the same company. OK, but what this has to do with abstractions? It is a prelude to this topic. As knowledge is being updated over the years, as mentioned, our way of thinking about what is good/bad abstraction also (hopefully) changes. And we start thinking about it as more than a way to be lazy in our code. By using it as some vinyl sheet to make it less “see-through”. Or as an enabler to skip “hard” work or better tests.
Where to start? How granular this should be? I would like to keep the language of my thoughts agnostic, but I struggle with this approach. It could mean keeping the discussion way too abstract. Seriously, a pun? Sorry… Anyhow, I will start with a view and opinion I have, where the point is to illustrate the thought process I have when it comes to introducing one into my code base. And then I can distill it down to some more concrete examples. And hopefully, you will find it helpful. Or at least, it will make you think about it. So, let’s start.
When it comes to defining an abstraction in the code I am working on, I start with the following question: What is the problem statement I am trying to model and simplify? Meaning, what is the subject of it? Boundaries of what scopes in its definition of the particular problem are a trick. For me, that starts with a good name. If I can’t come up with a descriptive name that explains a need for it, then I still have some thinking to do. Otherwise, I am gonna make something that will add no value. And the wrong abstraction is worse, in my opinion, than no abstraction at all. It will make it harder to understand the code, and it will make it harder to change it. And that is the opposite of what we are trying to achieve. So when my what has a clear and concise name, I can now hide that how behind it. And that is where the fun begins. How is the part why we need the abstraction? There is a “complex solution”, so we may think there is a way to make it “clearer” and easier to maintain. At least that is the idea. Notice that the process of making abstraction, in my case at least, starts from how or a solution we have for the problem. Not from abstraction first. At least not when it comes to domain problems and business logic. Edge dependencies, like databases, caching, etc. There I start with a simple contract first. The reason is simple, those also are being mixed into the domain language so I like to give them some meaningful naming to reflect the domain. So they don’t jump out as a sore point when reading the code. And another reason is that I like to keep those “external” dependencies enclosed within their own part of the project. To simplify updates and such.
Here I will then often get a blog post from Joel Spolsky, The Law of Leaky Abstractions, to show me why it is a bad idea. And while I believe he does make a valid point, there is also something I mentioned in the previous paragraph. I never design my abstractions based on the implementations they’re “hiding” behind them. Then the point, even by the definition of the word abstraction, is lost. I create them based on the requirements and how they fit into the code I am writing. Meaning if I need to have to cache my code, my abstraction will not bleed what is the implementation detail of it? Again, we work in teams and companies so sometimes we need to compromise what we think a good name is… With that “let me throw your words back into your face” comment prevention, let me explain what I mean with a simple example.
This is a pseudo-code, for all intents and purposes. And to prevent people from screaming. Let me start with the implementation, or how. It is simple code, for a reason to keep the discussion about the topic and not so much about the rest of the code. I would prefer to inject the actual dependency here, IDatabase, so this is just to illustrate the point.
Here my naming reflects what is the underlying implementation. I do believe it makes it tangible and just based on the name you can expect what you will find within. A Redis implementation of some sort. To improve on it, the name of it could be UserRedisClient
. Then again, I believe that the entire signature contributes to readable code. Meaning: RedisClient : IUserCacheStore
for me reads as a Redis client that is used to cache Users. Where the Store delegates responsibility from an abstract name to actual implementation. Which is Redis. So why “duplicate” it again? Then again, this is a discussion I am having on a daily basis, so at least here I don’t need to defend my opinions on this. It is just something I believe in, and let us go forward with this. So we found a poke point, interesting...
The usefulness of adding these kinds of abstractions can be debated, and often it is. How often you will swap Redis for something else? Or similar questions. I will put these internal, and external, debates in future writing that will be an extension of this.
I would argue that IUserCacheStore
is a good abstraction, or contract to be more precise, that doesn’t bleed anything further from it. When using this abstraction in our code, our non-functional requirements will also read nicely. They will not jump out from the rest of the context. Only when they wish to know what is “hidden” behind it, they will stumble upon this RedisClient
. And then cognitive load queue will be cleared from the previous context, which would be “clear”, and load a new item to keep in mind. And that is why I consider it a good abstraction. It hides a “complexity” till the point when the need to know more about it arises. Peeling off layers and giving away more information without overburdening, one level at a time.
That is why I prefer when talking about abstractions, using words like the interface more. They, in my opinion, are clearer and illustrate more of the reasoning we’re making. Not sure if I follow… A good interface, be that electronics, applications, and so on, provides a nice experience while hiding the entire complexity away from you. You don’t need to know how a toaster works, yet the interface is intuitive enough that most people can figure it out even without using the manual. And again, without you knowing anything about its internals and how that bread becomes crispy on a simple button push. Translating this into the development world, I think it maps really nicely as well. When talking about abstractions. They need to provide a certain level of “easy” to use and clarity, without hiding their intended purpose. Otherwise, as mentioned already, their purpose becomes then more of enabling us to be lazy with our tests or something along these lines. Those kinds of abstractions are just a noise in probably already noisy code. They’re more of a convenience or means to an end than thought-through solutions. I am not casting shade on anyone else, these are things I did. Still do sometimes. Be that from frustration or needing to get something done yesterday. We have jobs and sometimes compromises need to be made. Which is fine, as long we learn from our mistakes and maybe even go back at one point. And set it right. But whom am I kidding, once written, it stays there.
Design, or simply said thinking, is required to prevent us from introducing abstractions just for the sake of it. If they’re not contributing to making your solution “better”, where quantifying “better” is what you consider dear in your code base, then they’re making it “worse”. They should serve a purpose and not just pat our ego on the back, because we know better than the previous developers. When it feels like you’re adding a fresh coat of paint on top of it, because you don’t understand the solution, think refactoring first that “bad” code. And if the abstraction emerges out of it, so be it. You found a good reason for it while leaving the campsite cleaner than you found it. And improved on it for the next generations along the way. But adding to the pile of garbage a nice fence will not make it smell any better.
To summarize my view on abstractions/interfaces:
- They provide a clear and easy glance over a complex problem that is hidden behind them, where the knowledge of internal workings is not necessary for the particular context where they’re being applied and it would even distract away from it
- They don’t bleed, or expose, the internals of the solution they’re hiding away, otherwise, it is a pointless abstraction
- Avoid writing abstractions for the sake of unit testing and avoiding “hard” work, they add to the noise and subtract from the clarity and understanding
But as I’m here debating some ideal future, and where I am sharing my view in the ocean of others, it is a person’s opinion. I am still fresh in this world, contrary to what my work experience says on my resume. I am getting into a stage of questioning everything and everyone. Even myself, for the sake of making my knowledge more sound and defensible. When I need to stand my ground and without having any empirical evidence to support my claims, defend why I think something should be done in a certain way. And this gets more and more obvious the more roles get attached to your name on the company ladder.
Until next time, abstract away.