SOLID Principles

showcase-solid

Image by Ugonna Thelma from medium

Introduction

SOLID is an acronym for the five important principles of Object-Oriented Design (OOD).
These are rules and best practices that developers can follow to have understandable, testable, scalable, and maintainable code.

These principles might look similar to each other but don't target the same goal. It is possible to implement one principle while violating the other.

These principles were introduced by famous computer scientist, Robert C. Martin (aka Uncle Bob) in his 2000 paper Design Principles and Design Patterns.

SOLID stands for :

  • The Single Responsibility Principle
  • The Open-Closed Principle
  • The Liskov Substitution Principle
  • The Interface Segregation Principle
  • The Dependency Inversion Principle

Let's now look at each of the above principles at a time.

Note: Words like Class, Functions, Modules, Methods can be used interchangeably in this article.

S - Single Responsibility Principle

Single Responsibility Principle (SRP) states that a Class or Module should have one job to do and hence it should have only one reason to change.

Most of the time, you will be working in a collaborative environment and many teams will be working on the same module, this can lead to conflicts if SRP is not followed.

As Unix philosophy says:

"Do one thing and Do it well"

A good example would be :

class Rectangle { constructor(length, width) { this.length = length; this.width = width; } get area() { return this.length * this.width; } }

Above is a good example of SRP as the sole purpose of the above Class is to get the area of a rectangle and we would only change the class if we want to change the logic of getArea

A bad example would be :

class Rectangle { constructor(length, width) { this.length = length; this.width = width; } getArea() { return this.length * this.width; } saveData() { ... } }

The above example is bad because now we have more than one reason to change the class. One is when we want to change getArea logic and the second is when we want to change the saveData logic.

The solution would be to abstract saveData to its own class.

O - Open-Closed Principle

The Open-Closed principle states that a Class or Module should be open to extensions and closed for modifications.

This means that we should be able to add new features without changing the existing codebase.

Let's see how we can add a new feature to calculate perimeter to the example in the above section.

For example :

class Rectangle { constructor(length, width) { this.length = length; this.width = width; } get area() { return this.length * this.width; } get perimeter() { return 2 * (this.length + this.width); } }

In the above example, we added a new function perimeter without changing the existing code which satisfies the open-close principle.

L - Liskov Substitution Principle

Liskov Substitution Principle states that if we have a base class and a child class, then objects of the base class should be substituted by objects of child class without giving incorrect results.

It's is expected that a child class will inherit everything from its base class. A child class should only extend the behavior but never narrows it down.

This means that if we have a base Class A and a child Class B then we should be able to call any method of base class onto the object of child class without having incorrect results.

Example:

class Shape { get area() { return 0; } } class Rectangle extends Shape { constructor(length, width) { super(); this.length = length; this.width = width; } get area() { return this.length * this.width; } } class Square extends Shape { constructor(length) { super(); this.length = length; } get area() { return this.length ** 2; } } class Circle extends Shape { constructor(radius) { super(); this.radius = radius; } get area() { return Math.PI * this.radius ** 2; } } const shapes = [new Rectangle(1, 2), new Square(1, 2), new Circle(2)]; for (let s of shapes) { console.log(s.area); }

In the above example, Shape is the base class which is extended by child class Rectangle, Square, and Circle. Child classes inherit everything from the base class. These child classes override the existing getter area to their own specification.

I - Interface Segregation Principle

The Interface Segregation Principle states that clients should not be forced to depend on methods that they do not use.

This means that a class should only be implemented with those methods which are required to fulfill its purpose. Any other method and function should be removed or abstracted from the implementation.

This principle doesn't apply directly since javascript doesn't have interfaces but still, let's have a look at how we can do this in javascript.

A bad example would be:

class Rectangle { constructor(length, width) { this.length = length; this.width = width; } getArea() { return this.length * this.width; } saveDataToFile() { ... } }

The above example is bad because what if we dont want to save data then saveDataToFile method is useless here.

A good example would be:

class Rectangle { constructor(length, width, options) { this.length = length; this.width = width; this.options = options } getArea() { return this.length * this.width; this.saveDataToFile() } saveDataToFile() { if(this.options.save){ ... } } } const newRectangle = new Rectangle(5, 4, {save: true})

The above example is good because now saveDataToFile is not available to those who don't need it. It is only available when we pass the save option.

D - Dependency Inversion Principle

Dependency Inversion Principle states that :

  • High-level modules should not depend upon low-level modules. They should depend upon abstraction.
  • Abstractions should not depend upon details. Details should depend upon abstractions.

Here are some terminologies :

  • High-level modules: Class that requires a tool to perform an action.
  • Low-level modules: Tool which is used to perform an action.
  • Abstractions: Interface that connects the two classes.
  • Details: How the tool works

This means that the class should not implement the tool it requires to perform actions to its core and should be used with the interface to connect to the tool.

Class and Interface should not know the implementation of its dependencies but the tool must be compatible to be connected with the interface.

A Bad example would be:

class Request { constructor(url) { this.url = url; } get() { fetch(url) .then((res) => res.json()) .then((data) => console.log(data)); } }
Note: fetch API is used to make the HTTP request.

The above example is bad because the tool (fetch) which is used to make HTTP requests is a dependency of the class which violates the principle. Now, what if we want to use some other HTTP client.

A good example would be:

class Request { constructor(url) { this.url = url; } get() { httpClient(url).then((res) => console.log(res)); } } export default httpClient = (url) => { return axios.get(url); };

The above example is good because now it does not depend on any specific tool to work. we have abstracted the tool to the httpClient module now if we want to use some other HTTP client, we just have to modify the httpClient module.

Note: axios is an HTTP client to make the HTTP request.

Conclusion

In this article, we discussed five important principles, following these practices will allow you to have understandable, testable, scalable, and maintainable code with fewer conflicts while collaborating with teams.

I would highly recommend having a look at this The S.O.L.I.D Principles in Pictures. It is a very good article for visual learners.

Thank you for reading.

Resources

Thanks to these resources which helped me to write this article.