S.O.L.I.D: The 5 Golden Rules to Level Up Your Coding Skills
Tags: programming, oop, architecture, software
Published: 2024-02-05 01:32:06

In the realm of software development, a field known for its diverse and strongly held opinions, few practices have achieved consensus as a guaranteed path to becoming a better software engineer quite like the S.O.L.I.D principles.

The 5 golden rules, which were formalized in the early 2000s by Robert C. Martin, have drastically influenced the software development industry and set new standards for better code quality and decision-making process, preserving its relevance up to this present moment.

![SOLID Principles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l2mdlic5qf7f0ws36owf.jpg)

S.O.L.I.D principles are specifically designed to support the OOP (Object-Oriented Programming) paradigm. Hence, this article is designed for OOP developers who wish to level up their development skills and to write more elegant, maintainable and scalable code.

The language which will be used here is TypeScript, following common cross-language OOP concepts. Basic OOP knowledge is required.

<hr>

## 1. S = Single Responsibility Principle (SRP)

<br>

Single Responsibility Principle (SRP) is one of the five S.O.L.I.D principles, which states the each class should have only one responsibility, in order to preserve meaningful separation of concerns.

This patterns is a solution to a common anti-pattern called "The God Object" which simply refers to a class or object that holds too many responsibilities, making it difficult to understand, test and maintain.

Following the SRP rule helps making code components reusable, loosely-coupled and easily comprehensible. Let's explore this principle, showcasing an SRP violation and resolution.


### Global Declarations

```typescript
enum Color {
    BLUE = 'blue',
    GREEN = 'green',
    RED = 'red'
}

enum Size {
    SMALL = 'small',
    MEDIUM = 'medium',
    LARGE = 'large'
}

class Product {
    private _name: string;
    private _color: Color;
    private _size: Size;

    constructor (name: string, color: Color, size: Size) {
        this._name = name;
        this._color = color;
        this._size = size;
    }
    
    public get name(): string { return this._name; }
    public get color(): Color { return this._color; }
    public get size(): Size { return this._size; }
}
```

<br>

### Violation

In the following code, the ``ProductManager`` class is responsible for **both creation and storage of products**, violating the single-responsibility principle.

```typescript
class ProductManager {
    private _products: Product[] = [];

    createProduct (name: string, color: Color, size: Size): Product {
        return new Product(name, color, size);
    }

    storeProduct (product: Product): void {
        this._products.push(product);
    }

    getProducts (): Product[] {
        return this._products;
    }
}

const productManager: ProductManager = new ProductManager();

const product: Product = productManager.createProduct('Product 1', Color.BLUE, Size.LARGE);
productManager.storeProduct(product);

const allProducts: Product[] = productManager.getProducts();
```
<br>

### Resolution

Separating the handling of products creation and storage to two distinct classes reduces the number of responsibilities of ``ProductManager`` class. This approach further modularizes the code and makes it more maintainable.

```typescript
class ProductManager {
    createProduct (name: string, color: Color, size: Size): Product {
        return new Product(name, color, size);
    }
}

class ProductStorage {
    private _products: Product[] = [];

    storeProduct (product: Product): void {
        this._products.push(product);
    }

    getProducts (): Product[] {
        return this._products;
    }
}
```

#### Usage:

```typescript
const productManager: ProductManager = new ProductManager();
const productStorage: ProductStorage = new ProductStorage();

const product: Product = productManager.createProduct("Product 1", Color.BLUE, Size.LARGE);

productStorage.storeProduct(product);
const allProducts: Product[] = productStorage.getProducts();
```

<hr>

## 2. O = Open-Closed Principle (OCP)

<br>


> "Software entities should be open for extension but closed for modification"

The Open-Closed Principle (OCP) is all about _"write it once, write it well enough to be extensible and forget about it."_

The importance of this principle relies on the fact that a module may change from time to time based on new requirements. In case the new requirements arrive after the module was written, tested and uploaded to production, modifying this module is usually bad practice, especially when other modules depend on it. In order to prevent this situation, we can use the Open-Closed Principle.

### Global Declarations

```typescript
enum Color {
    BLUE = 'blue',
    GREEN = 'green',
    RED = 'red'
}

enum Size {
    SMALL = 'small',
    MEDIUM = 'medium',
    LARGE = 'large'
}

class Product {
    private _name: string;
    private _color: Color;
    private _size: Size;

    constructor (name: string, color: Color, size: Size) {
        this._name = name;
        this._color = color;
        this._size = size;
    }

    public get name(): string { return this._name; }
    public get color(): Color { return this._color; }
    public get size(): Size { return this._size; }
}

class Inventory {
    private _products: Product[] = [];

    public add(product: Product): void {
        this._products.push(product);
    }

    addArray(products: Product[]) {
        for (const product of products) {
            this.add(product);
        }
    }

    public get products(): Product[] {
        return this._products;
    }
}
```

### Violation

Let's describe a scenario where we implement a products filtering class. Let's add the ability to filter the products by color.

```typescript
class ProductsFilter {
    byColor(inventory: Inventory, color: Color): Product[] {
        return inventory.products.filter(p => p.color === color);
    }
}
```

We've tested and deployed this code to production.

A few days later the client requests for a new ability - filtering by size as well. We then modify the class to support the new requirement.

**The Open-Closed Principle is now violated!**

```typescript
class ProductsFilter {
    byColor(inventory: Inventory, color: Color): Product[] {
        return inventory.products.filter(p => p.color === color);
    }

    bySize(inventory: Inventory, size: Size): Product[] {
        return inventory.products.filter(p => p.size === size);
    }
}
```

### Resolution

The correct way to implement the filtering mechanism without violating OCP should be made using "Specifications" classes.

```typescript
abstract class Specification {
    public abstract isValid(product: Product): boolean;
}

class ColorSpecification extends Specification {
    private _color: Color;

    constructor (color) {
        super();
        this._color = color;
    }

    public isValid(product: Product): boolean {
        return product.color === this._color;
    }
}

class SizeSpecification extends Specification {
    private _size: Size;

    constructor (size) {
        super();
        this._size = size;
    }

    public isValid(product: Product): boolean {
        return product.size === this._size;
    }
}

// A robust mechanism to allow different combinations of specifications
class AndSpecification extends Specification {
    private _specifications: Specification[];

    // "...rest" operator, groups the arguments into an array
    constructor ((...specifications): Specification[]) {
        super();
        this._specifications = specifications;
    }

    public isValid (product: Product): boolean {
        return this._specifications.every(specification => specification.isValid(product));
    }
}

class ProductsFilter {
    public filter (inventory: Inventory, specification: Specification): Product[] {
        return inventory.products.filter(product => specification.isValid(product));
    }
}
```

#### Usage:

```typescript
const p1: Product = new Product('Apple', Color.GREEN, Size.LARGE);
const p2: Product = new Product('Pear', Color.GREEN, Size.LARGE);
const p3: Product = new Product('Grapes', Color.GREEN, Size.SMALL);
const p4: Product = new Product('Blueberries', Color.BLUE, Size.LARGE);
const p5: Product = new Product('Watermelon', Color.RED, Size.LARGE);

const inventory: Inventory = new Inventory();
inventory.addArray([p1, p2, p3, p4, p5]);

const greenColorSpec: ColorSpecification = new ColorSpecification(Color.GREEN);
const largeSizeSpec: SizeSpecification = new SizeSpecification(Size.LARGE);

const andSpec: AndSpecification = new AndSpecification(greenColorSpec, largeSizeSpec);
const productsFilter: ProductsFilter = new ProductsFilter();

const filteredProducts: Product[] = productsFilter.filter(inventory, andSpec); // All large green products
```

The filtering mechanism is now fully extensible. The existing classes should never be modified anymore.

In case there is a new filtering requirement, we simply create a new specification. Or maybe If the specification combinations need to be changed, this can be done easily by using the ``AndSpecification`` class.

<hr>

## 3. L = Liskov Substitution Principle (LSP)

<br>

The Liskov's Substitution Principle (LSP) is an important rule for flexibility and robustness of software components. It was introduced by Barbara Liskov, and became a foundational element of the S.O.L.I.D principles.

LSP states that **objects of a superclass should be replaceable with objects of the subclass without affecting the correctness of the program.** In other words, a subclass should extend the behaviors of a superclass without changing its original functionality. Adopting this approach leads to increased quality of software components, ensuring reusability and decreases unintended side effects.

### Violation

The example below illustrates a scenario where the Liskov Substitution Principle (LSP) is violated. An indication of this violation can be observed by examining the program's behavior when the ``Rectangle`` object is replaced with a ``Square`` object.

#### Declarations:

```typescript
class Rectangle {
    protected _width: number;
    protected _height: number;

    constructor (width: number, height: number) {
        this._width = width;
        this._height = height;
    }

    get width (): number { return this._width; }
    get height (): number { return this._height; }

    set width (width: number) { this._width = width; }
    set height (height: number) { this._height = height; }

    getArea (): number {
        return this._width * this._height;
    }
}

// A square is also rectangle
class Square extends Rectangle {
    get width (): number { return this._width; }
    get height (): number { return this._height; }
    
    set height (height: number) {
        this._height = this._width = height; // Changing both width & height
    }

    set width (width: number) {
        this._width = this._height = width; // Changing both width & height
    }
}

function increaseRectangleWidth(rectangle: Rectangle, byAmount: number) {
    rectangle.width += byAmount;
}
```


#### Usage:

```typescript
const rectangle: Rectangle = new Rectangle(5, 5);
const square: Square = new Square(5, 5);

console.log(rectangle.getArea()); // Expected: 25, Got: 25 (V)
console.log(square.getArea()); // Expected: 25, Got: 25 (V)

// LSP Violation Indication: Can't replace object 'rectangle' (superclass) with 'square' (subclass) since the results would be different.
increaseRectangleWidth(rectangle, 5);
increaseRectangleWidth(square, 5);

console.log(rectangle.getArea()); // Expected: 50, Got: 50 (V)

// LSP Violation, increaseRectangleWidth() changed both width and height of the square, unexpected behavior.
console.log(square.getArea()); //Expected: 50, Got: 100 (X)
```


### Resolution

The refactored code now adheres to LSP by ensuring that objects of the superclass ``Shape`` can be replaced with objects of the subclasses ``Rectangle`` and ``Square`` without affecting the correctness of the calculated area, also without introducing any unwanted side-effects that alter the program's behavior.


#### Declarations:

```typescript
abstract class Shape {
    public abstract getArea(): number;
}

class Rectangle extends Shape {
    private _width: number;
    private _height: number;
    
    constructor (width: number, height: number) {
        super();
        this._width = width;
        this._height = height;
    }

    getArea (): number { return this._width * this._height; }
}

class Square extends Shape {
    private _side: number;

    constructor (side: number) {
        super();
        this._side = side;
    }

    getArea (): number { return this._side * this._side; }
}

function displayArea (shape: Shape): void {
    console.log(shape.getArea());
}
```

#### Usage:

```typescript
const rectangle: Rectangle = new Rectangle(5, 10);
const square: Square = new Square(5);

// The rectangle's area is correctly calculated
displayArea(rectangle); // Expected: 50, Got: 50 (V)

// The square's area is correctly calculated
displayArea(square); // Expected: 25, Got: 25 (V)
```

<hr>

## 4. I = Interface Segregation Principle (ISP)

<br>

Interface Segregation Principle (ISP) emphasizes the importance of creating client-specific interfaces rather than one-size-fits-all.

This approach concentrates classes based on the client's needs, eliminating scenarios where a class must implement methods it does not actually use or need. 

By applying the Interface Segregation Principle, software systems can be built in much more flexible, easy to understand and easy to refactor manners. Let's take a look at an example.

### Violation

The ISP rule is violated here since ``Robot`` must implement the ``eat()`` function which is completely unnecessary.

```typescript
interface Worker {
    work(): void;
    eat(): void;
}

class Developer implements Worker {
    public work(): void {
        console.log('Coding..');
    }

    public eat(): void {
        console.log('Eating..');
    }
}

class Robot implements Worker {
    public work(): void {
        console.log('Building a car..');
    }

    // ISP Violation: Robot is forced to implement this function even when unnecessary
    public eat(): void {
        throw new Error('Cannot eat!');
    }
}
```


### Resolution

The example below represents a resolution for the problem we previously encountered. The interfaces are now more concise and more client-specific, allowing client classes to implement only the methods that are relevant to them.

```typescript
interface Workable {
    work(): void;
}

interface Eatable {
    eat(): void;
}

class Developer implements Workable, Eatable {
    public work(): void {
        console.log('Coding..');
    }

    public eat(): void {
        console.log('Eating...');
    }
}

class Robot implements Workable {
    public work(): void {
        console.log('Building a car..');
    }

    // No need to implement eat(), adhering ISP.
}
```

#### ISP Before & After:


![Interface Segregation Principle Before & After Refactor](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/szh5wkpkc30z4t91j5oy.png)

<hr>

## 5. D = Dependency Inversion Principle (DIP)

<br>

The Dependency Inversion Principle (DIP) is the final S.O.L.I.D principle with a focus on reducing coupling between low-level modules (e.g. data reading/writing) and high-level modules (that perform the key operations) by using abstractions.

DIP is crucial for designing software that is resilient to change, modular, and easy to update.

<u>**DIP Key Guidelines Are:**</u>

1. **High level modules should not depend on low level modules. Both should depend on abstractions.** Meaning that the functionality of the application should not rely on specific implementations, in order to make the system more flexible and easier to update or replace low-level implementations.

2. **Abstractions should not depend on details. Details should depend on abstractions.** This encourages the design to focus on <u>what</u> operations are actually needed rather than on <u>how</u> those operations are implemented.


### Violation

Let's take a look at an example that showcases a Dependency Inversion Principle (DIP) violation.

``MessageProcessor`` (high-level module) is tightly coupled and directly dependent on the ``FileLogger`` (low-level module), violating the principle because it does not depend on an abstraction layer, but rather on a concrete class implementation.

**<u>Bonus</u>:** There is also a violation of the Open-Closed Principle (OCP). If we would like to change the logging mechanism to write to the database instead of to a file, we would be forced to directly modify the ``MessageProcessor`` function.

```typescript
import fs from 'fs';

// Low Level Module
class FileLogger {
    logMessage(message: string): void {
        fs.writeFileSync('somefile.txt', message);
    }
}

// High Level Module
class MessageProcessor {
    // DIP Violation: This high-level module is is tightly coupled with the low-level module (FileLogger), making the system less flexible and harder to maintain or extend.
    private logger = new FileLogger();

    processMessage(message: string): void {
        this.logger.logMessage(message);
    }
}
```


### Resolution

The following refactored code represents the changes needed to be made in order to adhere to the Dependency Inversion Principle (DIP). In contrast to the previous example where the high-level class ``MessageProcessor`` held a private property of the concrete low-level class ``FileLogger``, it now instead holds a private property of type ``Logger`` - the interface that represents the abstraction layer.

This better approach reduces dependencies between the classes, thus making the code much more scalable and maintainable.

#### Declarations:

```typescript
import fs from 'fs';

// Abstraction Layer
interface Logger {
    logMessage(message: string): void;
}

// Low Level Module #1
class FileLogger implements Logger {
    logMessage(message: string): void {
        fs.writeFileSync('somefile.txt', message);
    }
}

// Low Level Module #2
class ConsoleLogger implements Logger {
    logMessage(message: string): void {
        console.log(message);
    }
}

// High Level Module
class MessageProcessor {
    // Resolved: The high level module is now loosely coupled with the low level logger modules.
    private _logger: Logger;

    constructor (logger: Logger) {
        this._logger = logger;
    }

    processMessage (message: string): void {
        this._logger.logMessage(message);
    }
}
```


#### Usage:

```typescript
const fileLogger = new FileLogger();
const consoleLogger = new ConsoleLogger();

// Now the logging mechanism can be easily replaced
const messageProcessor = new MessageProcessor(consoleLogger);
messageProcessor.processMessage('Hello');
```


#### DIP Before & After:


![Dependency Inversion Principle Before & After Refactor](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kju8qfpie60b104vuz90.png)


## Conclusion

By following the S.O.L.I.D principles, developers can avoid common pitfalls such as tight coupling, lack of flexibility, poor code reusability and general maintainance difficulties when developing or maintaining software systems at any scale. Mastering these principles is another step towards becoming a better software engineer.