SOLID Principles in Action: Real-World Examples Using Typescript

SOLID Principles in Action: Real-World Examples Using Typescript

8 min read
Edit this post
Read on DevTo
TypeScript

SOLID is an acronym used in object-oriented design to achieve maintainable and reusable classes.

The SOLID principle doesn't depend on any particular programming language. It is a principle that could and should be adopted into any medium-large project.

SOLID is 5 principles that stand for:

  • Single-responsibility Principle
  • Open-Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency inversion Principle

We will look into each principle in detail with some practical examples using TypeScript. All below examples will use classes but the same applies to functions.

By applying the SOLID principles, developers can achieve many benefits, including improved code readability, increased code reusability, and better software design and architecture. These principles provide a set of guidelines for creating maintainable, scalable, and flexible code.

Single Responsibility principle (SRP)

As the name implies, class or function should have a single responsibility and only one reason to change.

This rule is very simple but often is violated the reason you find yourself in a situation where you don’t know to which class, a specific method belongs to. The benefits of following this rule result in better code readability, since it's easier to read and test simple small classes or methods.

Let's take a look at the incorrect example belove

1import { writeFile } from "fs";
2
3class HttpClient {
4 constructor(private baseUrl: string) {}
5
6 // Make an HTTP GET request to the specified URL
7 public async get(url: string): Promise<any> {
8 const response = await fetch(this.baseUrl + url);
9 return response.json();
10 }
11
12 // Make an HTTP POST request to the specified URL
13 public async post(url: string, body: any): Promise<any> {
14 const response = await fetch(this.baseUrl + url, {
15 method: "POST",
16 headers: {
17 "Content-Type": "application/json"
18 },
19 body: JSON.stringify(body)
20 });
21 return response.json();
22 }
23
24 public async writeFile(filePath: string, contents: string) {
25 const data = JSON.stringify(contents);
26 writeFile(filePath, data, err => {
27 if (err) {
28 throw err;
29 }
30 console.log("JSON data is saved.");
31 });
32 }
33}

In the above example, the HttpClient class is responsible for making HTTP requests, and writing files to disk. This violates the SRP, as the class is performing multiple unrelated tasks.

A better way to implement this class would be to split the different responsibilities into separate classes so each class can be responsible only for a single task.

Here is an example of how the HttpClient class can be refactored to adhere to the SRP:

1class HttpClient {
2 constructor(private baseUrl: string) {}
3
4 // Make an HTTP GET request to the specified URL
5 public async get(url: string): Promise<any> {
6 const response = await fetch(this.baseUrl + url);
7 return response.json();
8 }
9
10 // Make an HTTP POST request to the specified URL
11 public async post(url: string, body: any): Promise<any> {
12 const response = await fetch(this.baseUrl + url, {
13 method: "POST",
14 headers: {
15 "Content-Type": "application/json"
16 },
17 body: JSON.stringify(body)
18 });
19 return response.json();
20 }
21}

And now we will create a separate class for the writeFile method called FileManager that will be responsible for handling files

1class FileManager {
2 public async writeFile(filePath: string, contents: string) {
3 writeFile(filePath, data, err => {
4 if (err) {
5 throw err;
6 }
7 console.log("JSON data is saved.");
8 });
9 }
10}

If there will be a new requirement for reading/deleting files we can add them to the related class FileManager

Open-closed principle (OCP)

The Open/Closed Principle states that a class should be open for extension but closed for modification. This means that the class should not be changed once it has been implemented, but it should provide a way for other developers to extend it by adding new methods

We have an Invoice class that is responsible for saving invoice data to storage that was passed to the constructor

1class Invoice {
2 constructor(public persistanceStorage: FileManager | DatabaseManager) {}
3
4 public async persistInvoice(data) {
5 if (this.persistanceStorage instanceof FileManager) {
6 this.persistanceStorage.writeFile();
7 } else if (this.persistanceStorage instanceof DatabaseManager) {
8 this.persistanceStorage.writeDb();
9 }
10 }
11}

The issue with this class is that it is tied to a specific class implementation of storing data. If we need to change how data is stored (e.g. by using Redis cache), we would have to modify the class and add another storage to the if/else clause.

To make the class more flexible and maintainable, we can create an abstraction that separates the storage logic from the Invoice class. This will allow us to change the behavior of the class without modifying its code and will make it easier to swap out different storage implementations as needed.

Let’s create an interface called PersistanceStorage

1interface PersistanceStorage {
2 saveData(data);
3}

Now each of our storage classes should implement the contract of this interface.

1class FileManager implements PersistanceStorage {
2 public async storeData(data) {
3 // Store data to local file
4 }
5}
6
7class DatabaseManager implements PersistanceStorage {
8 public async storeData(data) {
9 // Store data to database
10 }
11}
12
13class CacheManager {
14 public async storeData(data) {
15 // Store data into cache
16 }
17}

Let’s change our Invoice class

1class Invoice {
2 constructor(public persistanceStorage: PersistanceStorage) {}
3
4 public async persistInvoice(data) {
5 this.persistanceStorage.saveData(data);
6 }
7}

Now when we are adding new persistence storage, we don’t have to modify our Invoice class, instead, we need to pass appropriate storage to the constructor that implements PersistanceStorage interface.

Liskov Substitution (LS)

LS states that objects of a superclass should be replaceable with instances of a subclass. Subtype behavior should match base type behavior as defined in the base type specification.

Subclasses should not change the behavior of our parent class.

Let’s create a base class called Database that represents a generic database, and two subclasses called SQLiteDatabase and PostgresSQLDatabase. The Database class has a method called getData() that is used to retrieve data from the database, and the SQLiteDatabase and PostgresSQLDatabase classes override this method to implement their versions of the getData() behavior.

Let’s look at an example that breaks the LS principle

1class Database {
2 getData(): Array<T> {
3 // Code to retrieve data from the database
4 return data;
5 }
6}
7
8class SQLiteDatabase extends Database {
9 getData(): Array<T> {
10 const result = db.findAll();
11 return data;
12 }
13}
14
15class PostgresSQLDatabase extends Database {
16 getData(): Array<T> {
17 const result = db.findOne();
18 return result;
19 }
20}
21
22let database: Database;
23
24database = new SQLiteDatabase();
25let data = database.getData(); // This comply with Liskov substitution
26
27database = new PostgresSQLDatabase();
28let data = database.getData(); // This violates Liskov substitution

The above example violates the LS principle since we accept that our child classes comply with the parent class API contract, to fix it we need to update PostgresSQLDatabase to return a list of data instead of a single entry.

Here is the correct implementation of the LS

1class Database {
2 getData(): Array<T> {
3 // Code to retrieve data from the database
4 return data;
5 }
6}
7
8class SQLiteDatabase extends Database {
9 getData(): Array<T> {
10 const result = db.findAll();
11 return data;
12 }
13}
14
15class PostgresSQLDatabase extends Database {
16 getData(): Array<T> {
17 const result = db.findAll();
18 return result;
19 }
20}

Now both of our classes can be used interchangeably.

Interface Segregation (IS)

Interface Segregation means the principle states that many interfaces are better than one general-purpose interface and classes should not be forced to implement a function they do not need to use.

Let’s take as an example one of our previous Interfaces PersistanceStorage and slightly modify it

1interface PersistanceStorage {
2 saveData(data);
3 readData();
4 deleteData();
5 moveData();
6}

Now if we implement this interface on the DatabaseManager we will be forced to implement all methods, but moveData is not directly related to the database. Let’s fix this and split our interface into two separate interfaces.

1interface PersistanceStorage {
2 saveData(data);
3 readData();
4 deleteData();
5}
6
7interface PersistanceFileStorage {
8 moveData();
9}

The new interface PersistanceFileStorage implements all methods defined in PersistanceStorage now we can use PersistanceFileStorage interface on our FileManager class

1class FileManager implements PersistanceFileStorage {}

Dependency Inversion

Software systems consist of modules, which we conditionally divide into low-level and high-level ones.

Example of Low-level utilities:

  • Database queries
  • HTTP requests
  • DOM rendering and manipulations

High-level modules (components that provide complex functionality and are relatively more stable) should not depend on low-level modules (components that provide basic functionality and are likely to change more frequently). Both should depend on abstractions (interfaces).

Let's take a Database class example that provides a generic interface for connecting to and querying a database. We also have a UserRepository class that uses the Database class to store and retrieve user data from the database. Without applying the Dependency Inversion Principle, we might have the following code:

1class Database {
2 getData(): Array<T> {
3 // Code to retrieve data from the database
4 return data;
5 }
6
7 connect() {
8 // Create database connection
9 }
10}

UserRepository class

1class UserRepository {
2 private database: Database;
3
4 constructor() {
5 this.database = new Database();
6 }
7
8 public async getUserById(id: number): Promise<User> {
9 this.database.connect();
10 const result = this.database.query(`SELECT * FROM users WHERE id = ${id}`);
11 return result;
12 }
13}

In this example, the UserRepository depends directly on the Database class, and it creates a new instance of the Database in its constructor. This makes the UserRepository difficult to test and maintain, as we would have to change the UserRepository code if we wanted to use a different implementation of the Database class (e.g. a different database engine).

We can apply the Dependency Inversion Principle to this code by defining an interface for the Database class and depending on that interface in the UserRepository class, like this:

1interface Database {
2 connect(): void;
3 getData(): Array<T>;
4}
5
6class PostgreSQLDatabase implements Database {
7 connect(): void {
8 // Connect to a MySQL database
9 }
10
11 getData(): Array<T> {
12 // Execute the given query on the PostgreSQL database
13 }
14}
15
16class MongoDBDatabase implements Database {
17 connect(): void {
18 // Connect to a MongoDB database
19 }
20
21 getData(): Array<T> {
22 // Execute the given query on the MongoDB database
23 }
24}
25
26class UserRepository {
27 private database: Database;
28
29 constructor(database: Database) {
30 this.database = database;
31 }
32
33 public async getUserById(id: number): Promise<User> {
34 this.database.connect();
35 const result = this.database.getData();
36 return result;
37 }
38}

In this example, the UserRepository depends on the Database interface rather than on a concrete implementation of the Database class. This means that we can use any class that implements the Database interface as the database for the UserRepository, without having to change the UserRepository code.

Conclusion

In conclusion, the SOLID principles are a set of guidelines for object-oriented design that can help developers to create maintainable, reusable, and scalable code. By applying the SOLID principles in their Typescript projects, developers can create high-quality code that is well-suited to meet the needs and requirements of their users.

Comments