Understanding SOLID Principles

Understanding SOLID Principles

The SOLID principles are a set of five principles, amongst others, guiding how software is designed to be less fragile, less rigid, more testable, maintainable, and readable. It influences how we write code in such a way that future changes do not threaten the stability of existing software.

The five principles are:

  • Single Responsibility Principle.

  • Open Closed Principle.

  • Liskov Substitution Principle.

  • Interface Segregation Principle.

  • Dependency Inversion Principle.

This article explains each of the solid principles with examples in TypeScript. Even if you do not understand TypeScript, the article is written to be simple and easy to understand, like codebases written following these principles.

Prerequisites

This article assumes you have basic knowledge of TypeScript and object-oriented programming. As the examples are all classes written in TypeScript.

Single Responsibility Principle

The principle states that a class should have only one reason to change. In order to achieve that, a class should only do one thing alone. This makes a class smaller and less fragile. It also ensures that people can work on various parts of the codebase without having a merge conflict

class User {
    create() {
        console.log('User created')
    }

    index() {
        console.log('Users returned')
    }

    fetch() {
        console.log('User returned')
    }

    sendNotification() {
        console.log('Notification sent')
    }

    login() {
        console.log('Login successful')
    }

    logout() {
        console.log('Logout successful')
    }
}

Looking at the example above, we can see that the Single Responsibility Principle is not being obeyed. This class handles other issues, such as authentication and notification. So to follow the principle, we will move the sendNotification method to a different class where everything pertaining to notifications is being handled and move the login and logout method to an authentication class where login and logout will be taken care of.

Open-Closed Principle

This principle states that a class should only be opened for extension and closed for modification. This principle ensures that:

  • Existing classes do not have new bugs introduced to them.

  • New additions are tested in isolation.

  • The single responsibility principle is not broken.

This means that we should only be able to add new functionality to a class and not edit existing code. Looking at the example from the Single Responsibility Principle, let's assume the business tells us we have new users all the way from the planet Mars and more coming from other planets later. Our business has decided to have them sign up. We do not need to edit the create method to have them come in, instead, we can make the user class abstract, the create method abstract, and create a new class for Martians with a create method to overwrite the existing create method on the user class. While also having access to the initial methods on the User class.

abstract class User {
    abstract create(): void

    index() {
        console.log('Users returned')
    }

    fetch() {
        console.log('User returned')
    }
}

class Earthlings {
    create() {
        console.log('User created')
    }
}

class Martian extends User {
    create() {
        console.log('User created')
    }
}

From the example above, we moved the existing implementation of the create function to New Classes. So while the user class contains methods solely for all users, the Martian class will also contain methods and properties for users from mars.

Interface Segregation Principle

This principle is all about having separate interfaces. If a class does not implement a property or function in an interface, then it has no dealings with that interface in the first place.

So going back to our Authentication class we mentioned earlier. Our interface for it will look like

interface Auth {
    login(): void;

    logout(): void;
}

class Authentication implements Auth{
    login() {

    }

    logout() {

    }
}

Liskov Substitution Principle

A superclass can be swapped with a subclass, and the method using the class will not notice the change. Using the principle if such a swap happens, nothing should break, and the system should perform as if no change happened. This means that if we are to override an existing method, the new implementation should exhibit similar behaviour to the existing method in the base class. For example,

Looking at the image, we realise that the plastic duck cannot be substituted for a live duck.

Another example will be

class Animal {
    walk() {}

    speak() {}

    eat() {}
}

class Lion extends Animal {}

class snake extends Animal {}

From our example, we realised that this is a bad example since a snake cannot walk but crawl. So let’s have an example that adheres to this principle

class Animal {
    speak() {}

    eat() {}
}

class WalkingAnimal extends Animal {
    walk() {}
}

class CrawlingAnimal extends Animal {
    crawl() {}
}

class Lion extends WalkingAnimal {}

class Snake extends CrawlingAnimal {}

From this last example, we can see that, Lion can be substituted with Animal or WalkingAnimal class, and it will function well.

Dependency Inversion Principle

Looking at our earlier examples. Let's assume the Auth class needs to send a notification whenever a user logs in. Our auth class will look like this:

class Authentication {
    login() {
        const alert = new Alert();
        alert.send()

        console.log('User logged in')
    }

    logout() {

    }
}

class Alert {
    send() {
        console.log('Notification sent')
    }
}

The issue with this first is that the open and closed principle can easily be broken once we decide to switch our notification class, while already breaking the Dependency Inversion Principle.

So what is the Dependency Inversion Principle? The principle states that:

  • A high-level class should not depend on a low-level class.

  • A high-level class and a low-level class should depend on an abstraction.

  • An abstraction should not depend on any class implementation.

From the example, we can see that our high-level class is the authentication class while the low-level class is the Alert class.

So what is an abstraction? To illustrate, you own a car but cannot describe how the car ignition comes on in full detail. But the car manufacturers decided a long time ago that such knowledge should not be a burden to you. So they provide you with a simple interface such as a point where you insert a key and turn the key or a button. From our illustration, we can say that abstraction is simply the act of separating the content of a class from its implementation.

The example below shows how Dependency Inversion can be achieved.

class Authentication {
    login(alert: IAlert) {
        alert.send()

        console.log('User logged in')
    }

    logout() {

    }
}

interface IAlert {
    send() :void
}

class Alert implements IAlert {
    send(): void {
        console.log('Notification sent')
    }
}

const auth = new Authentication()
auth.login(new Alert())

Looking at the example we can see that both the Authentication and the Alert class depend on the IAlert interface. Also, this method makes the code less rigid. Let’s assume that for some reason management decides to introduce sending alerts using SMS instead of emails. And a new class is created. All it needs to do is implement the IAlert interface and be passed as an argument in the login method and we are good to go.

Conclusion

If you have made it to this point, thanks so much and I hope this article was informative.

We have covered the five SOLID principles with practical examples of how they can be applied. I hope you get to apply it as much as possible to make your code less fragile, rigid, and more testable, understandable, and maintainable. Thanks.