SOLID Principles in Action: Real-World Examples Using 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 URL7 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 URL13 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 URL5 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 URL11 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 file4 }5}6
7class DatabaseManager implements PersistanceStorage {8 public async storeData(data) {9 // Store data to database10 }11}12
13class CacheManager {14 public async storeData(data) {15 // Store data into cache16 }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 database4 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 substitution26
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 database4 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 database4 return data;5 }6
7 connect() {8 // Create database connection9 }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 database9 }10
11 getData(): Array<T> {12 // Execute the given query on the PostgreSQL database13 }14}15
16class MongoDBDatabase implements Database {17 connect(): void {18 // Connect to a MongoDB database19 }20
21 getData(): Array<T> {22 // Execute the given query on the MongoDB database23 }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.