paint-brush
Clean Code: Single Responsibility, Open/Closed, Liskov Substitution SOLID Principles in TS [Part 4]by@alenaananich
4,777 reads
4,777 reads

Clean Code: Single Responsibility, Open/Closed, Liskov Substitution SOLID Principles in TS [Part 4]

by Alena AnanichNovember 9th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

In this article, we will take a look at the three first SOLID principles: The single Responsibility Principle, the Open/Closed Principle and the Liskov Substitution Principle on practical examples.
featured image - Clean Code: Single Responsibility, Open/Closed, Liskov Substitution SOLID Principles in TS [Part 4]
Alena Ananich HackerNoon profile picture


Previous Parts:


We are continuing to consider approaches to making our code clean, flexible, and maintainable. And now, let’s start to investigate clean architectural solutions.


SOLID principles were introduced by Robert C. Martin and are widely regarded as best practices in object-oriented programming and software design.


In this article, we will take a look at the three first SOLID principles: The Single Responsibility Principle, the Open/Closed Principle, and the Liskov Substitution Principle on practical examples.

Table of Contents:

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. Liskov Substitution Principle

1. Single Responsibility Principle (SRP)

A class should have only one reason to change


In other words, a class should have a single responsibility or job. It is important because a class with a single responsibility is easier to understand, modify, and maintain. Changes in one area of the code won't ripple through the entire system, reducing the risk of introducing bugs.


Let’s take a look at a practical example and ways to follow the single responsibility principle:

// Bad
class UserSettingsService {
  constructor(user: IUser) {
    this.user = user;
  }

  changeSettings(settings: IUserSettings): void {
    if (this.isUserValidated()) {
      // ...
    }
  }

  getUserInfo(): Promise<IUserSettings> {
    // ...
  }

  async isUserValidated(): Promise<boolean> {
    const userInfo = await this.getUserInfo();
    // ...
  }
}

In this example, our class carries out actions in different directions: it sets up context, changes it, and validates it.


To follow SRP, we need to split these different responsibilities.

// Better
class UserAuth {
  constructor(user: IUser) {
    this.user = user;
  }

  getUserInfo(): Promise<IUserSettings> {
    // ...
  }

  async isUserValidated(): boolean {
    const userInfo = await this.getUserInfo();
    // ...
  }
}

class UserSettings {
  constructor(user: IUser) {
    this.user = user;
    this.auth = new UserAuth(user);
  }

changeSettings(settings: IUserSettings): void {
    if (this.auth.isUserValidated()) {
      // ...
    }
  }
}


Why Is It Important?

SRP aims to enhance code modularity, minimizing challenges stemming from inter-dependencies. By organizing code into functions, separated classes, and promoting modularity, it becomes more reusable, saving time that might otherwise be spent re-coding existing functionality.

2. Open/Closed Principle (OCP)

Software entities (classes, modules, functions) should be open for extension but closed for modification


You should be able to add new functionality without changing existing code. It is important because by adhering to this principle, you can introduce new features or components to your system without risking the stability of the existing code.


It promotes code reusability and reduces the need for extensive changes when extending functionality.

// Bad
class Product {
 
  id: number;
  name: string[];
  price: number;

  protected constructor(id: number, name: string[], price: number) {
    this.id = id;
    this.name = name;
    this.price = price;
  }
}

class Ananas extends Product {
  constructor(id: number, name: string[], price: number) {
    super(id, name, price);
  }
}

class Banana extends Product {
  constructor(id: number, name: string[], price: string) {
     super(id, name, price);
   }
} 

class HttpRequestCost {
  constructor(product: Product) {
    this.product = product;
  }

  getDeliveryCost(): number {
     if (product instanceOf Ananas) {
        return requestAnanas(url).then(...);
    }
    
    if (product instanceOf Banana) {
        return requestBanana(url).then(...);
    }
  }
}


function requestAnanas(url: string): Promise<ICost> {
    // logic for ananas
}

function requestBanana(url: string): Promise<ICost> {
    // logic for bananas
}

In this example, the problem is in class HttpRequestCost, which, in method, getDeliveryCost contains conditions for the calculation of different types of products, and we use separate methods for each type of product. So, if we need to add a new type of product, we should modify the HttpRequestCost class, and it is not safe; we could get unexpected results.


To avoid it, we should create an abstract method request in Product class without realizations. The particular realization will have inherited the classes: Ananas and Banana. They will realize the request for themself.


HttpRequestCost will take the product parameter following the Product class interface, and when we pass particular dependencies in HttpRequestCost, it will already realize request method for itself.

// Better
abstract class Product {

  id: number;
  name: string[];
  price: string;

  constructor(id: number, name: string[], price: string) {
    this.id = id;
    this.name = name;
    this.price = price;
  }

  abstract request(url: string): void;
}


class Ananas extends Product {
   constructor(id: number, name: string[], price: string) {
     super(id, name, price);
   }

   request(url: string): void {
      // logic for ananas
   }
}

class Banana extends Product {
   constructor(id: number, name: string[], price: string) {
     super(id, name, price);
   }

   request(url: string): void {
      // logic for bananas
   }
}


class HttpRequestCost {
  constructor(product: Product) {
    this.product = product;
  }

  request(): Promise<void> {
    return this.product.request(url).then(...);
  }
}


Why Is It Important?

Following this principle, you will reduce code coupling and save the system from unpredictable behavior.

3. Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.


To understand this principle, let’s take a look at this example:

// Bad
class Worker {
  work(): void {/../}

  access(): void {
    console.log('Have an access to closed perimeter');
  }
}

class Programmer extends Worker {
  createDatabase(): void {/../}
}

class Seller extends Worker {
  sale(): void {/../}
}

class Designer extends Worker {
  access(): void {
    throwError('No access');
  }
}

In this example, we have an issue with the Contractor class. Designer, Programmer, and Seller are all Workers, and they inherited from the parent class Worker. But at the same time, Designers do not have access to closed perimeter because they are Contractors, not Employees. And we have overridden the access method and broke the Liskov Substitution Principle.


This principle tells us that if we replace the superclass Worker with its subclass, for example Designer class, the functionality should not be broken. But if we do it, the functionality of the Programmer class will be broken - the access method will have unexpected realizations from Designer class.


Following the Liskov Substitution Principle, we should not rewrite the realizations in subclasses but need to create new layers of abstraction where we define particular realizations for each type of abstraction.


Let’s correct it:

// Better
class Worker {
  work(): void {/../}
}

class Employee extends Worker {
  access(): void {
    console.log('Have an access to closed perimeter');
  }
}

class Contractor extends Worker {
  addNewContract(): void {/../}
}

class Programmer extends Employee {
  createDatabase(): void {/../}
}

class Saler extends Employee {
  sale(): void {/../}
}

class Designer extends Contractor {
  makeDesign(): void {/../}
}

We created new layers of abstractions Employee and Contractor and moved access method to the Employee class and defined specific realization. If we replace Worker class with subclass Contractor, the Worker functionality will not be broken.

Why Is It Important?

If you adhere to LSP, you can substitute any subclass instance wherever a base class instance is expected, and the program should still work as intended. This promotes code reuse, modularity, and makes your code more resilient to changes.


In the next article, we will take a look at Interface Segregation and Dependency Inversion SOLID principles.