DEV Community

Cover image for Application of S.O.D. in frontend development
Marble IT
Marble IT

Posted on

Application of S.O.D. in frontend development

In this post, we will show that SOLID matters for every software engineer, regardless of the area of expertise. In web UI development, engineers mostly work with HTML, CSS, and client-side JavaScript, which do not necessarily require knowledge of object-oriented design. This leads to engineers who never get in touch with SOLID, and less code quality in general. Let's fix this by introducing some of the principles that are a must in client-side development.

Motivation

By knowing SOLID, you enhance the quality of your code in many ways - you benefit from readability, maintainability, minimal repetition and redundancy, agility, and cost of development in general. Not to mention the stress relief during RFCs (requests for change) and code maintenance!

Principles are initially easy to adopt, but once you get better at them, they will reveal more and more things.

Introduction

The term "SOLID" was first introduced by the famous engineer Robert C. Martin. It marks a set of five principles that lead to better code quality. Although they are mostly followed in object-oriented design, they are also useful in front-end development.

These principles are not rules, they are tenets that engineers need to adopt and use responsibly in their projects.

The acronym "SOLID" stands for:

  • Single responsibility principle
  • Open-closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

Liskov substitution and interface segregation principles are more related to object oriented design and aren't that crucial in frontend development, but, of course, in larger frontend applications, you will also find those. For today's post, let's go through S, O and D!

Single responsibility principle

This principle is really easy to adopt and demonstrate through simple CSS. The principle itself states that every module we write (classes, functions in JS, or even CSS selectors) should have one responsibility or, to be more precise, one reason to change. This means that we shouldn't write complex ninja classes, functions, or selectors that do and define everything. Instead, we should dissolve that module into smaller modules, each having one functionality. A level of separation of the modules in your code is called "granularity." As an engineer, it is your responsibility to find a perfect level of granularity for your project. Don't be stingy, but do not go nuts.

Let's illustrate this principle with a CSS example. Let's imagine that we have a button in our project, that should be 40px in height, 100px in width and always red. We can write this in CSS:

> .btn-red { 
    width: 100px; 
    height: 40px; 
    background-color: red;
}
Enter fullscreen mode Exit fullscreen mode

Now, a realistic scenario is that, at some point, we will have an RFC for a new button color, let's say blue. In that case, assuming that we cannot change our old code due to the fact that it is used all over the project, we can write something like this:

.btn-red { 
    width: 100px; 
    height: 40px; 
    background-color: red;
}
Enter fullscreen mode Exit fullscreen mode
.btn-blue { 
    width: 100px; 
    height: 40px; 
    background-color: blue;
}
Enter fullscreen mode Exit fullscreen mode

This is not good since we duplicated some of our previous code. This is a small duplication, but it can become larger and create more significant problems. For example, you can have buttons in 10 colors, and you need to change their height to 30px. You would need to write that change in 10 places in your code. Let's take a look at a better example.

We could follow the "S" principle and write our initial code like this (separated functionalities):

.btn { 
    width: 100px; 
    height: 40px;
}
Enter fullscreen mode Exit fullscreen mode
.btn.red { 
    background-color: red;
}
Enter fullscreen mode Exit fullscreen mode

With the properly granulated code, we can easily do something like this:

.btn { 
    width: 100px; 
    height: 40px;
}
Enter fullscreen mode Exit fullscreen mode
.btn.red { 
    background-color: red;
}
Enter fullscreen mode Exit fullscreen mode
.btn.blue{ 
    background-color: blue;
}
Enter fullscreen mode Exit fullscreen mode

We didn't change our original code, but we added a new feature, without code duplication. This is the point of single responsibility!

Important thing to note: we could granulate our code even further without getting any benefits. For example, we could've written something like this:

.width-100 { 
    width: 100px;
}
Enter fullscreen mode Exit fullscreen mode
.height-40 { 
    height: 40px;
}
Enter fullscreen mode Exit fullscreen mode

And so on, for every CSS property. This level of granularity brings no value to our specific scenario, so watch out, do not over-engineer.

Open-closed principle

This principle states that the code that you write should be OPEN for EXTENSION and CLOSED for MODIFICATION. What this basically means is that the developer that continues the work after you doesn't need to change the existing code but should be able to easily extend it. We can illustrate this principle on the exact same CSS example as the previous one.

By writing code like this, we allow the next developer to easily extend our code and add a new color:

.btn { 
    width: 100px; 
    height: 40px;
}
Enter fullscreen mode Exit fullscreen mode
.btn.orange { 
    background-color: orange;
}
Enter fullscreen mode Exit fullscreen mode
.btn.custom{ 
    background-color: #f2f2f2;
}
Enter fullscreen mode Exit fullscreen mode

On the other hand, our original example was CLOSED for extension.

Dependency inversion principle

Well, this principle comes in naturally with JavaScript since it doesn't support types, so there isn't much point in mentioning it in a JS context. But if you are using TypeScript, you need to know this principle!

The dependency inversion principle states that nothing should depend on concrete implementations but on abstractions instead. What this means is that if you are using a certain module in your code, like FacebookService (e.g., for some logic related to Facebook SDK), it should reside behind its own abstraction (e.g. SocialNetworkService), so the actual implementation is minimally mentioned in the code. The reason to do this is to achieve an easier switch if, at some point, that FacebookService needs to become, let's say, Google Service.

To follow the dependency inversion principle, you'd first need to create your interface with the same method signatures as the original implementation. Second, your concrete implementation should extend your newly created abstraction. Third, you'd need to write the logic in your code to ensure that a concrete implementation is returned each time someone requests the abstraction. Let's see how we can achieve this in TypeScript:

At first, we will have a concrete service that looks something like this:

export default class FacebookService { 
    login() { 
        // Some logic with Facebook SDK 
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, we need to create our interface:

export default interface SocialNetworkInterface { 
    login: () => void // The only important thing is that the method signature is the same as the concrete one
}
Enter fullscreen mode Exit fullscreen mode

Then, our concrete implementation should extend the interface:

export default class FacebookService extends SocialNetworkInterface ...
Enter fullscreen mode Exit fullscreen mode

And, last but not least, we need to create a way to always get our concrete implementation behind our abstraction. We can use a factory method to do this:

class SocialNetworkServiceFactory { 
    getService() : SocialNetworkService { 
        return new FacebookService(); // This is the only place in the code where the concrete implementation will be mentioned so that it can be easily swapped with GoogleService 
    }
}

export default new SocialNetworkFactory();
Enter fullscreen mode Exit fullscreen mode

So, when we want to use our FacebookService in our code, we would write something like this:

let socialNetworkService: SocialNetworkService = socialNetworkFactory.getService(); // This will create a FacebookService, even though we haven't mentioned it in this line. A job well done!
Enter fullscreen mode Exit fullscreen mode

Some will argue that this isn't, in fact, dependency inversion, but a simple factory method instead. To them, I say - Do not mix dependency INVERSION with dependency INJECTION. Dependency inversion is a PRINCIPLE (that we achieved with this solution), and dependency injection is a PATTERN that is used in some technologies to achieve dependency inversion and inversion of control.

Ivan Kockarevic

Top comments (0)