Solid Software Design Principles

In this article, we will see SOLID principles and how to implement those principles while building a software application. Robert. C. Martin (popularly known as Uncle Bob) introduced the SOLID principle. Later, Michael Feather re-orders the principle to form the acronym. It helps to implement better code design, maintainability, and extendability. 

These principles will guide you to:

Before we get into the implementation details, Let’s see what SOLID stands for:

S - Single Responsibility Principle.

O - Open/Closed Principle.

L - Liskov Substitution Principle.

I - Interface Segregation Principle.

D - Dependency Inversion Principle.

Single Responsibility Principle

Single responsibility principle is one of the popular principles in the software design process. This principle states that a class (or) an entity should have only one responsibility and reason to change its behavior. 

Let’s understand this principle with an example:

class User {
  public name: string;
  public age: string;
  constructor(name: string, age: string) {
    this.name = name;
    this.age = age;
  }

  fetchUser() {
    //return this.httpService.get("www.user.com/api/users")
  }

  setName(name: string) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

Here, we have a User class with setName, getName, and fetchUser methods. It has every property and method related to that class. However, the class violates the single responsibility pricing as it has a fetchUser method that calls an endpoint to fetch user information. It adds additional responsibility to the class. 

When the application starts to grow, it impacts its modularity and testability. We can avoid that by refactoring the code based on the single responsibility principle. 

class User {
  public name: string;
  public age: string;
  constructor(name: string, age: string) {
    this.name = name;
    this.age = age;
  }
}

class UserService {
  public user: User;
  constructor(user: User){
      this.user = user;
  }
 
  fetchUser() {
    // return this.httpService.get("www.user.com/api/users")
  }

  setName(name: string) {
      this.user.name = name;
    return this.user;
  }
}

As you can see, each class has single responsibility as the User class is responsible for managing the User entity, and UserService is responsible for getting and setting user data. 

Doing this can avoid coupling and improve the application's modularity. Uncle bob, the inventor of SOLID principles, compares this principle with an organized wardrobe. An unorganized room (or) wardrobe represents spaghetti code. It is difficult to maintain the codebase in the long run. By using the single responsibility principle, it can avoid coupling.

Open/Closed principle

Open-Closed principle states that software should allow extending the functionality but restricts when there’s any modification to the existing one. This principle minimizes changes in existing code by extending the functionality. Let’s understand this principle with an example.

class User {
  constructor(name, role, email) {
    this.name = name;
    this.role = role;
    this.email = email;
  }

  getDetails() {
    return `This is ${this.name}, email is: ${this.email}`
  }
}


const user = new User("Ganesh", "USER", "test@gmail.com")

console.log(user.getDetails())

Here, we have a User class which has getDetails method to fetch the user details. We can invoke the method using the user object.

const user = new User("Scout", "USER", "test@gmail.com");

In the future, what if we want to display user details based on the user role? To do that, we need to modify the existing code by adding a condition on getDetails to check the user roles and show user details based on that.

class User {
  constructor(name, role, email) {
    this.name = name;
    this.role = role;
    this.email = email;
  }

  getDetails() {
    if (this.role === "ADMIN") {
      return `Hey, ${this.name}. Here are your admin details: ${this.email}`
    }
    else {
      return `This is ${this.name}, email is: ${this.email}`
    }
  }
}


const user = new User("Ganesh", "USER", "test@gmail.com")
const admin = new User("Sample", "ADMIN", "test@gmail.com")
console.log(user.getDetails())
console.log(admin.getDetails())

But it violates the open/closed principle by bringing in some modification to the code which can break existing functionality. Let’s refactor the code based on the principle.

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }

  getDetails() {
    return `This is ${this.name}, email is: ${this.email}`
  }
}

class AppUser extends User {

  constructor(name, email) {
    super(name, email)
  }

  getDetails() {
    return `This is Application User ${this.name}, email is : ${this.email}`
  }
}

class Admin extends User {
  constructor(name, email) {
    super(name, email)
  }

  getDetails() {
    return `This is Admin ${this.name}, email is : ${this.email}`
  }
}

const appUser = new AppUser("Ganesh", "appuser@gmail.com")
const admin = new Admin("Sample", "admin@gmail.com")

console.log(appUser.getDetails())
console.log(admin.getDetails())

As you can see, we have class User which acts as a parent class. We can extend that class to create a new one based on our requirements. Here, we have AppUser class which overrides the getDetails method of the parent class. The same goes for the Admin class as well. We can still access the properties and method from the parent class and override the method if needed. That way, we can avoid breaking the existing functionality and implement a new one. 

Liskov substitution principle

This principle was introduced by Barbara Liskov in 1987. According to the statement,

Let Φ(x) be a property provable about objects x of type T, then Φ(y) should be true for objects y of type S where S is a subtype of T.”

To put it in simple terms, when you replace an object of your superclass with an object of a subclass, it should work the same.

You should be able to replace a parent class and its methods with any of its child classes and its method without breaking an existing system.

Let’s look into it with an example:

class Vehicle {
  setColor(color) {
    this.color = color
  }

  getColor() {
    return this.color
  }
}

class ModelA extends Vehicle {
  setEngine(cc) {
    this.cc = cc;
  }

  getDetails() {
    return `Model has ${this.cc} power`
  }
}

class ModelB extends Vehicle {
  setEngine(cc) {
    this.cc = cc;
  }

  getDetails() {
    return `Model has ${this.cc} power`
  }
}

const modelA = new ModelA()
modelA.setColor("Red")

console.log(modelA.getColor())

Here, we have three classes Vehicle, ModelA, and ModelB. ModelA, ModelB extends the base class Vehicle. When we extend the Vehicle class on ModelA, we can access parent class methods in the subclass. This principle enforces that it should follow the standard in an application.

Interface Segregation Principle

Interface segregation principle states that a class or entity should never force it to implement methods it doesn’t need while implementing or extending the functionality. Let’s look into it with an example;

Consider that you have an interface, IUserInfo, that implements two methods. 

interface IUserInfo {
  getEmail(): string;

  getName(): string;
}

Classes that implement “IUserInfo” has to declare those two methods even if they don’t need those methods for the specific classes.

class User1 implements IUserInfo {
  getEmail() {
    return ""
  }

  getName() {
    return ""
  }
}

class User2 implements IUserInfo {
  getEmail() {
    return ""
  }

  getName() {
    return ""
  }
}

For example, User2 just needs to implement getEmail method. But, it also needs to implement getName since it implements the IUserInfo interface.

But, it violates the Interface segregation principle since it implements methods it doesn’t use. To solve this, let’s refactor the code according to the principle,

interface IUserInfo {
  getEmail(): string;
}

interface IUserInfoEnhanced {
  getEmail(): string;

  getName(): string;
}

Here, we separate the interfaced based on the requirement. Whenever a class needs a method, it can implement it based on the requirement.

class User1 implements IUserInfoEnhanced {
  getEmail() {
    return ""
  }

  getName() {
    return ""
  }
}

class User2 implements IUserInfo {
  getEmail() {
    return ""
  }
}

It solves the problem by implementing an interface that is relevant to the requirement. For the User2 class, we just need to implement the getEmail method. It avoids the need to implement methods that don’t use. 

Dependency Inversion Principle.

This principle targets to achieve the system to have modules loosely coupled. So, high-level modules are independent and reusable. It helps to avoid the impacts when there are changes in low-level modules. 

In simple terms, high-level and low-level modules should depend on abstractions instead of depending on each other. Let’s understand it with an example,

class UserService {
  //...
}

class UserController {
  public userService: UserService
  constructor(userService: UserService) {
    this.userService = userService;
  }

  async getUsers() {
    // ...
    const data = await this.userService.getUsers();
    // ...
  }
}

Here, we have UserService and UserController classes. We use the constructor function to provide UserService into UserController class. In UserController class, we have the getUsers method that invokes UserService.getUsers function. 

As you can see, a high-level module depends on a low-level module. This dependency can affect an application's modularity, which violates the dependency inversion principle. Let’s refactor the code by inverting the dependency.

interface IUserService {
  getUsers(): Promise<IUser>
}

class UserService implements IUserService {
  getUsers() {
    //...
  }
}

class UserController {
  public userService: IUserService;
  constructor(userService: IUserService) {
    this.userService = userService
  }

  async get() {
    // ...
    const data = await this.userService.getUsers()
    // ...
  }
}

Here, We solved the coupling by inverting the dependency. It involves abstracting the method into an interface and managing the dependency of a high-level module with that abstraction. In that way, even if there are any changes in UserService, it doesn’t impact the high-level module, i.e., UserController

Conclusion.

This article has covered SOLID design principles and how we can implement those principles in an application. SOLID principles guide us to design resilient, easy-to-maintain software that can extend without breaking the existing functionality. Using these principles properly will help build a system that is easy to understand and helps to focus on building cool features instead of spending time to understand the code. 

It’s all about writing better code that is resilient and bug-free. In the modern era of software development, there are a lot of tools that help us to achieve that. One such tool is ScoutAPM, which helps monitor the application and alerts when a performance impacts the code's functionality.