Previous Parts: Clean Code: Functions and Methods in TypeScript [Part 1] Clean Code: Naming and Code Composition in TypeScript [Part 2] Clean Code: Classes and Objects in TypeScript [Part 3] 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: Single Responsibility Principle Open/Closed Principle 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 , which, in method, 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 class, and it is not safe; we could get unexpected results. HttpRequestCost getDeliveryCost HttpRequestCost To avoid it, we should create an abstract method in class without realizations. The particular realization will have inherited the classes: Ananas and Banana. They will realize the request for themself. request Product will take the parameter following the class interface, and when we pass particular dependencies in , it will already realize method for itself. HttpRequestCost product Product HttpRequestCost request // 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 class. , , and are all Workers, and they inherited from the parent class . But at the same time, Designers do not have access to closed perimeter because they are Contractors, not Employees. And we have overridden the method and broke the Liskov Substitution Principle. Contractor Designer Programmer Seller Worker access This principle tells us that if we replace the superclass with its subclass, for example class, the functionality should not be broken. But if we do it, the functionality of the class will be broken - the method will have unexpected realizations from class. Worker Designer Programmer access Designer 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 and and moved method to the class and defined specific realization. If we replace class with subclass , the functionality will not be broken. Employee Contractor access Employee Worker Contractor Worker 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.