Nov 10, 2021
Immature Abstractions
A few days ago we had a really insightful conversation with a colleague of mine about how we would implement a new feature in one of our projects.
I will explain in broad terms what this feature is about and then I'll outline two possible solutions for this problem with one of them being "better" than the other.
The Problem
Let's say we have built a widget that from now on we'll call OurWidget. This widget achieves many things (called tasks), and one of those is to support and "use" other 3rd party widgets and their SDKs as well.
Which 3rd party widget should be used by OurWidget is dictated by a variable called widgetType which is given from a user input to the application.
For example, assume that OurWidget needs to accomplish a task called openWidget
which basically opens the widget. And let's also assume that widgetType is FacebookWidget. So basically the task openWidget
must open the FacebookWidget.
The steps OurWidget needs to execute in order to achieve this task are:
- Run some boilerplate code which is the same for all widgets.
- Initialize FacebookWidget SDK.
- Run some boilerplate code to prepare for the open task.
- Finally open FacebookWidget by using the corresponding function from the SDK, e.g
FB.openWidget()
.
If the widgetType input was GoogleWidget then the algorithm above would be (notice how steps 1 and 3 are the same):
- Run some boilerplate code which is the same for all widgets.
- Initialize GoogleWidget SDK.
- Run same boilerplate code to prepare for the open task.
- Finally open GoogleWidget by using the corresponding function from the SDK, e.g
Google.openWidget()
.
Of course we want our awesome OurWidget to support many 3rd party widgets as possible, like InstagramWidget, ViberWidget, WhateverWidget etc... Eventually we don't know how many widgets we'll support so our solution must be robust and scalable in order to easily add, remove, or modify widgets in the future.
Conceptually, we need to support N tasks for M widgets (N and M can't be known beforehand and are prone for change in the future). A naive diagram that demonstrates that, is shown below:
Think in Abstract Terms
Personally I hate duplicated code. When I'm writing code and I'm about to spot a code duplication or when I'm "feeling" that a duplication is coming, I make sure to refactor a function or to improve something somewhere in order to avoid that duplication. In that way I'm confident that when I need to change something in the future I won't have to change it at many places!
However, there are many cases that code duplication is "necessary" and is way more preferable than making the code more abstract. And I'm about to find that probably this is the case for the current problem we're talking about as well.
My First Solution
Let's take a closer look to the two pseudo-algorithms presented before, regarding the openWidget
task:
FacebookWidget (widgetType="facebook")
- Run some boilerplate code which is the same for all widgets.
- Initialize FacebookWidget SDK.
- Run some boilerplate code to prepare for the open task.
- Finally pen FacebookWidget by using the corresponding function from the SDK, e.g
FB.openWidget()
.
GoogleWidget (widgetType="google")
- Run some boilerplate code which is the same for all widgets.
- Initialize GoogleWidget SDK.
- Run same boilerplate code to prepare for the open task.
- Finally open GoogleWidget by using the corresponding function from the SDK, e.g
Google.openWidget()
.
You're spotting it as well right? The steps 1 and 3 are exactly the same for the two different widgets (and it will be the same for any future widget). So my mind is thinking, there's the duplication, I need to make it abstract!
An abstract implementation:
import FB from 'facebook';
import Google from 'google';
const Tasks = {
init(widgetType) {
// ... 5 boilerplate lines for init ...
switch (widgetType){
case "Facebook":
FB.init();
case "Google":
Google.init();
default:
break;
}
}
open(widgetType) {
// ... 5 boilerplate lines for open ...
switch (widgetType){
case "Facebook":
FB.openWidget();
case "Google":
Google.openWidget();
default:
break;
}
}
close(widgetType) {
// ... 5 boilerplate lines for close ...
switch (widgetType){
case "Facebook":
FB.closeWidget();
case "Google":
Google.closeWidget();
default:
break;
}
}
}
// And then compose the tasks and the widgets
const {open, close} = Tasks;
const createWidget = (widgetType) => ({
init: () => init(widgetType),
open: () => open(widgetType),
close: () => close(widgetType)
})
const Facebook = createWidget('Facebook');
const Google = createWidget('Google');
// So for the Open Facebook Task:
Facebook.init();
Facebook.open();
// And for the Open Google Task:
Google.init();
Google.open();
// same for other tasks added...
And... Now I'm happy! I managed to avoid any code duplication, make the implementation abstract & smart and able to support all the widgets of the world! If we need to add a widget we'll just add a new case at the switch blocks for each of the tasks.
Or... not?
Immature Abstraction
After the discussion with my colleague where I proudly presented him with my abstract implementation above, he suggested that this abstraction albeit smart it could bring maintainability & scalability problems in the future. That's why it would be probably for the better if we approached this problem in a less "abstract" way.
I was hesitant at first, as I couldn't believe how non-abstract implementation with so many code duplications (imagine N*M where N tasks M widgets) would be more scalable and maintainable in the future.
But now I get it...
Code duplication is better than an immature abstraction.
And in my case this was probably an immature and "hurried" abstraction.
There are many reasons why this is probably an inappropriate abstraction - most of those reasons are hypothetical "what ifs" but still they need to be taken into account in order to ensure scalability for this project.
For example, what if in order for FacebookWidget to actually "open", the user must login first or do some other action which has nothing to do with openWidget
but it's really specific to Facebook? The open
function would need to be more convoluted to handle that.
Also the testing of these components would be more complex as well because there would be cases where the open
function tests fail and we don't know why as it could be broken in many places for various reasons.
A Less Abstract Approach
In order to deliver this feature in a bullet-proof way, we need to make the implementation more "isolated" for each widget and avoid all these complex abstractions.
A simple way that we can solve this problem is seen below:
import FB from 'fb';
import Google from 'google';
const Facebook = {
init() {
/// ... 5 lines same for all init functions
FB.init();
}
open() {
// ... 5 lines same for all widgets open functions ...
Fb.openWidget();
}
close() {
Fb.closeWidget();
// ... 5 lines same for all widgets close functions ...
}
}
const Google = {
init () {
/// ... 5 lines same for all init functions
Google.init();
}
open() {
// ... 5 lines same for all widgets open functions ...
Google.openWidget();
}
close() {
Google.closeWidget();
// ... 5 lines same for all widgets close functions ...
}
}
We can see here how we traded duplication for better handling of requirements and a more isolated (less abstract) approach. This implementation is at a better place handling any new requirements and changes (specific for each widget) in the future. Also the testing of these components would be easier as they are isolated (not composed as in the previous implementation).
Having said all that, I still believe that code abstraction and thinking in abstract terms when trying to solve a problem is really important and pretty fundamental in programming. But knowing when to apply the correct abstraction is even more important!
Catch you later! 😃
Newsletter
Subscribe to my mailing list
Subscribe to get my latest content by email. I won't send you spam, I promise. Unsubscribe at any time.