Explanation of SOLID in OOP

Introduction

SOLID is a set of five fundamental principles that support enhancing maintainability and ease of extension for future software development. Introduced by software engineer Robert C. Martin, also known as "Uncle Bob," in the book "Design Principles and Design Patterns," the SOLID principles include:

  • S - Single Responsibility Principle
  • O - Open/Closed Principle
  • L - Liskov Substitution Principle
  • I - Interface Segregation Principle
  • D - Dependency Inversion Principle

Below, we'll provide detailed explanations and analysis for each principle.

Note that the examples in this article are implemented using TypeScript, but you can rewrite them in other object-oriented programming languages.

1. Single Responsibility Principle (SRP)

A class should have one and only one reason to change, meaning that a class should have only one job.

This is considered the simplest and most crucial principle because it relates to most of the other principles. Simply put, when implementing a class/method, it should serve only one specific task. If it has more than one responsibility, it's advisable to split those responsibilities into multiple classes or methods. This practice benefits future maintenance, as when there's a need to modify the functionality of a class/method, we only need to make changes within that class/method without affecting others in the application.

Below is an example illustrating a violation of this principle.

class Car {
name: string
brand: string

// format car info
getCarInfoText() {
return "Name: " + this.name + ". Brand: " + this.brand;
}
getCarInfoHTML() {
return "<span>" + this.name + " " + this.brand + "</span>";
}
getCarInfoJson() {
return { name: this.name, brand: this.brand }
}

// store data
saveToDatabase() { }
saveToFile() { }
}


Here's an example showing a violation of the SRP principle because the Car class has too many unrelated functions like formatting info and storing data. These functions should be split into different classes to make things clearer and reduce the complexity of the source code.

// only car info
class Car {
name: string
brand: string
}

// only use for format
class Formatter {
formatCarText(car: Car) {
return 'Name: ' + car.name + '. Brand: ' + car.brand
}
formatCarHtml(car: Car) {
return '<span>' + car.name + ' ' + car.brand + '</span>'
}
formatCarJson(car: Car) {
return {name: car.name, brand: car.brand}
}
}

// only use for store data
class Store {
saveToDatabase() {}
saveToFile() {}
}


2. Open/Closed Principle (OCP)

Objects or entities should be open for extension but closed for modification

This principle means that a class should be designed in a way that allows new functionality to be added without altering its existing code. To achieve this, we can utilize inheritance, interfaces, or composition.

interface Shape {
calculateArea(): number;
}

class Circle implements Shape {
private radius: number;

constructor(radius: number) {
this.radius = radius;
}

calculateArea(): number {
return Math.PI * this.radius * this.radius;
}
}

class Rectangle implements Shape {
private width: number;
private height: number;

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

calculateArea(): number {
return this.width * this.height;
}
}


In the example above, the Shape interface defines a method called calculateArea used to calculate the area of a shape. Both the Circle and Rectangle classes, when implementing this interface, must define their own way of calculating the area for each shape.

This implementation approach is beneficial for future extension. If we add more shapes in the future (such as triangles, quadrilaterals, etc.), we only need to implement the Shape interface similarly and define the method to calculate the area for each shape, without needing to modify the existing classes. This avoids disrupting the existing logic of the system.


3. Liskov Substitution Principle (LSP)

This principle was proposed by Barbara Liskov in 1987.

Its essence is as follows:

Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.


In simpler terms for object-oriented programming, the principle is understood as:

Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.


Below is an example illustrating a violation of LSP. In reality, we know that a square is a type of rectangle with equal width and height. However, when implementing methods in the Square class that violate the behavior of the Rectangle class, it means that LSP is being violated.

class Rectangle {
height: number
width: number

setHeight(height: number) {
this.height = height
}

setWidth(width: number) {
this.width = width
}

calculateArea() {
return this.height * this.width
}
}

class Square extends Rectangle {
// change behavior of super class
setHeight(height: number) {
this.height = height
this.width = height
}

// change behavior of super class
setWidth(width: number) {
this.height = width
this.width = width
}
}

const rect = new Rectangle()
rect.setHeight(10)
rect.setWidth(5)
console.log(rect.calculateArea()) // 5 * 10

const rect1 = new Square()
rect1.setHeight(10)
rect1.setWidth(5)
console.log(rect1.calculateArea()) // result correct but break LSP

In this example, when the Square class implements the setWidth and setHeight methods from the Rectangle class, it violates the LSP because it changes both the width and height to be equal.

To ensure that the program doesn't violate the LSP, it's better to create a parent class, such as the Shape class, and then have both Square and Rectangle inherit from that class.


Additional Note

This principle is highly abstract and prone to violation if you don't fully understand the concept. In object-oriented programming, we often build classes based on real-life concepts and objects, such as "a square is a type of rectangle" or "a penguin is a bird." However, you can't directly translate these relationships into source code. Remember, "In real life, A is B (a square is a rectangle), but it doesn't necessarily mean that class A should inherit from class B. Class A should only inherit from class B if class A can substitute for class B."


4. Interface Segregation Principle (ISP)

A client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use.

The ISP encourages breaking down interfaces into smaller parts so that classes don't have to implement unrelated methods. This helps reduce dependency on unnecessary methods and makes the source code more flexible, easier to extend, and maintain.

Here's an example of a violation of the ISP:

interface Animal {
eat(): void
swim(): void
fly(): void
}

class Fish implements Animal {
eat() {}
swim() {}
fly() {
throw new Error('Fish can not fly')
}
}

class Bird implements Animal {
eat() {}
swim() {
throw new Error('Bird can not swim')
}
fly() {}
}

Because the Animal interface has many methods, and some methods may not be applicable to certain species of animals. When the Fish and Bird classes implement the Animal interface, they have to implement all methods, including unnecessary ones. This leads to wasted effort and increases the complexity of the program unnecessarily.


The solution is to split the Animal interface into smaller interfaces as follows:

interface Animal {
eat(): void
}
interface Bird {
fly(): void
}
interface Fish {
swim(): void
}

class Dog implements Animal {
eat() {}
}

class Sparrow implements Animal, Bird {
eat() {}
fly() {}
}

class Swan implements Animal, Bird {
eat() {}
swim() {}
fly() {}
}


5. Dependency Inversion Principle (DIP)

Entities must depend on abstractions, not on concretions. It states that the high-level module must not depend on the low-level module, but they should depend on abstractions

In simpler terms:

  • High-level modules should not rely on low-level modules; both should rely on abstractions.
  • Abstractions should not depend on details; details should depend on abstractions.


Here's an example of creating a DataExporter class that allows exporting data based on the provided `Exporter` (either ExcelExporter or PdfExporter). The `export` method is defined in the Exporter interface, making it easy to create additional exporters for use without needing to change the current source code.

interface Exporter {
export(data): void
}

class ExcelExporter implements Exporter {
export(data): void {
console.log('Export excel', data)
}
}

class PdfExporter implements Exporter {
export(data): void {
console.log('Export csv', data)
}
}

class DataExporter {
private exporter: Exporter

constructor(exporter: Exporter) {
this.exporter = exporter
}

async export(): Promise<void> {
const data = await this.fetchData()
this.exporter.export(data)
}

private async fetchData() {
return 'Faked data'
}
}

const excelExporter = new ExcelExporter()
const pdfExporter = new PdfExporter()

const dataExporterExcel = new DataExporter(excelExporter)
dataExporterExcel.export()

const dataExporterPdf = new DataExporter(pdfExporter)
dataExporterPdf.export()


It's important to note that the Dependency Inversion Principle differs from Dependency Injection because Dependency Inversion is a principle, while Dependency Injection is a design pattern. Dependency Injection is just one of the ways to implement Dependency Inversion.


Conclusion

The 5 SOLID principles can be implemented in most object-oriented programming languages like Java, C#, TypeScript, JavaScript, Python, etc. SOLID provides a foundational framework that helps developers build source code that is easy to understand, flexible, easily extendable, enhances system maintainability, and minimizes the risk of issues.

If you have any suggestions or questions regarding the content of the article, please don't hesitate to leave a comment below!

Comments

Popular posts from this blog

Kubernetes Practice Series

NodeJS Practice Series

Docker Practice Series

React Practice Series

Sitemap

Setting up Kubernetes Dashboard with Kind

Deploying a NodeJS Server on Google Kubernetes Engine

DevOps Practice Series

Create API Gateway with fast-gateway

A Handy Guide to Using Dynamic Import in JavaScript