13

Solid Principles Explained in Simple Language

Solid Principles Explained in Simple Language

What are solid principles? - Explained using real examples. How to implement S.O.L.I.D principles in simple language using TypeScript.


What exactly is S.O.L.I.D?

Each letter in the S.O.L.I.D. stands for a principle or rule that defines how the code should be written. The term SOLID was coined by an American programmer Robert Cecil Martin AKA Uncle Bob around the year 2000.

uncle-bob-robert-cecil-martin

Uncle Bob might have coined this term SOLID, However, each principle has its unique origin and different people who invented/distributed them. Here's the list of principles SOLID consists:

In this excersize we'll look at each of the principle and how it makes the code/software better with examples in TypeScript. These principles are language independent and not literal.

Single-Responsiblity Principle:

As per official definition the Single-Responsibility Principle states that:

A class should have one and only one reason to change.

This principle is by far the most important of all them. What that means in simple language is the classes in the software source code should have one and only job. However, in reality this might apply to code beyond classes for example methods in classes.

Let's start with below UserService class as an example for understanding the above principle. As per the naming and the principle if interpreted correctly the following class should handle only one type of tasks.

Before applying single responsiblity principle:


  export class UserService {
      private databaseService: DatabaseService;
      public constructor(databaseService: DatabaseService) {
          this.databaseService = new DatabaseService();
      }

      public get(id: string): Promise<User> {
          return this.databaseService.users.getById(id);
      }

      public update(id: string, data: Partial<User>): Promise<User> {
          const user = this.get(id);
          if(!user) {
              throw new Error('The requested user does not exists');
          }

          return this.databaseService.users.updateById(id, data);
      }

      public postComment(data: Partial<Comment>): Promise<Comment> {

          return this.databaseService.comments.create(data);
      }
  }

As you can guess these principles are not really measurable as there's no strong quantification and the context could be different in different cases. One can really feel them rather than following some standard set of rules. Since we're able to mutually agree that this service handling user related methods is OK - We can go ahead and take out the method related to comment from this service.

Its easy to notice that the class is not only handling the user related actions for example - get and update but it also doing something extra - postComment. The postComment method does not have anything to do with the user entity and shouldn't be present in this class. Now let's fix this.

After applying single responsiblity principle:


export class UserService {
    private databaseService: DatabaseService;
    public constructor(databaseService: DatabaseService) {
        this.databaseService = new DatabaseService();
    }

    public get(id: string): Promise<User> {
        return this.databaseService.users.getById(id);
    }

    public update(id: string, data: Partial<User>): Promise<User> {
        const user = this.get(id);
        if(!user) {
            throw new Error('The requested user does not exists');
        }

        return this.databaseService.users.updateById(id, data);
    }
}

Okay, Looks cleaner now this class is only handling methods related to one single entity - User. However, Could we do something more to have single responsiblity in the methods? get method here as of now are not doing much but simple db call.

We can see that the update is doing two things first fetching the data and validating it and then the actual update. If we take out the validation part from this method we can make the code more "single responsible".

After applying single responsiblity principle to the methods:

export class UserService {
    private databaseService: DatabaseService;
    public constructor(databaseService: DatabaseService) {
        this.databaseService = new DatabaseService();
    }

    public get(id: string): Promise<User> {
        return this.databaseService.users.getById(id);
    }

    public update(id: string, data: Partial<User>): Promise<User> {

        return this.databaseService.users.updateById(id, data);
    }

    private validateUser(id: string) {
        const user = this.get(id);
        if(!user) {
            throw new Error('The requested user does not exists');
        }
    }
}

Much better from where we started. We can even a go step ahead and create a separate helper class to handle the validation logic for the user service methods this will not only keep the code clean but also reduce the complexity of this service as well as keeping the file lengths sensible.

Often it can be seen that its good to start creating classes/methods for doing smallest unit of work so we can avoid future refactor - for example instaed of having a service class to handle all user entity related methods we can start from creating a class for every opertion we can think on the user entity. We can create classes like:

  • UserUpdateService: Handles only and only updates to the user entity
  • UserCreateService: Handles only and only creation of the user entity
  • UserGetService: Handles only and only fetching of the user entity

Open-Closed Principle:

As per official definition the Open-Closed Principle states that:

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

At first this statement might sound like understandable, but how do we implement it in code? Lets start with understanding what modification and extension means.

Modification:

Let's take an example of the code where we expect some new type of behaviour to be added in future for example a Printer service class.

In this example we're hanling different type of files for printing such as - image, pdf, doc. Now lets say a new requirement comes in and we also need to print video type of files. Now with this style of code we have no option but to modify the printer class and add another if/switch statement which handles the video type of files.


  export class Printer {
      public print(content: string, type: string) {
          if(type == 'image') {
              // logic to print image
          }
          if(type == 'pdf') {
              // logic to print pdf
          }
          if(type == 'doc') {
              // logic to print doc
          }
      }
  }

  new Printer().print('acme.png', 'img');
  new Printer().print('acme.pdf', 'pdf');
  new Printer().print('acme.doc', 'doc');

The principle talks about such modification - instead of keep modifying one class is there a way we can add new feature by extension?

Extension:

Whenever you have new requirement or behaviour change coming in we should be able to extend the behaviour without modifying the existing classes. Let's try to implment this below.


  interface IPrintable {
      print: (content: string) => void
  }

  export class ImagePrinter implements IPrintable {
      public print(content: string) {
          // logic to print image
      }
  }

  export class PdfPrinter implements IPrintable {
      public print(content: string) {
          // logic to print image
      }
  }

  export class DocPrinter implements IPrintable {
      public print(content: string) {
          // logic to print image
      }
  }

  new ImagePrinter().print('acme.png');
  new PdfPrinter().print('acme.pdf');
  new DocPrinter().print('acme.doc');

Here, instead of adding modifications to the origin Printer class we added abstraction using an interface. Now we have a dedicated class to handle specific type of behaviour required for specific type of files.

Now let's try to resolve our original requirement which is to handle video files - Instead of modifying any of the existing classes we can now create a new class which basically extends the original code.


  export class VideoPrinter implements IPrintable {
      public print(content: string) {
          // logic to print video
      }
  }

  new VideoPrinter().print('acme.mp4');

Here, as you can notice we have not only used extension to achive open-closed principle but we're also having single responsiblity in our code by having each class to handle only one type of behaviour.

Liskov Substitution Principle:

The Liskov Substitution Principle states:

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.

This means if two classes are extending the same parent class both can be used in the places where the parent class can be used. Lets look at the following example.

Here, We have a base class Invoice and two classes extending it ServiceInvoice and ProductInvoice despite the two classes have different methods and members both can be substituted in the printTotal function as they're extending from the same parent class.

export class Invoice {
    private itemAmounts: number[];
    constructor(itemAmounts: number[]) {
        this.itemAmounts = itemAmounts;
    }

    public getTotal() {
        return this.itemAmounts.reduce((acc, curr) => acc + curr, 0);
    }
}

export class ServiceInvoice extends Invoice {
    private worker: string;

    constructor(itemAmounts: number[], worker: string) {
        super(itemAmounts);
        this.worker = worker;
    }

    public getWorkerName() {
        return this.worker;
    }
}

export class ProductInvoice extends Invoice {
    private category: string;

    constructor(itemAmounts: number[], category: string) {
        super(itemAmounts);

        this.category = category;
    }

    public getCategoryName() {
        return this.category;
    }
}

function printTotal(invoice: Invoice) {
    console.log(invoice.getTotal());
}

printTotal(new ServiceInvoice([10,20], 'Jaremy'));
printTotal(new ProductInvoice([10,20], 'Books'));

Interface Segregation Principle:

As per Interface Segregation Principle:

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.

That means if a extending object/class does not need to implement a method of the interface it shouldn't be forced to do so. We should have smaller interfaces based on the use case so the client classes only has to worry about the methods they need.

In this example we start with an interface IDevice having two methods getCost and getDimensions. At the start our classes WashingMachine and Fridge implement both of these methods so we're already satisfying the principle.


  export interface IDevice {
      getCost: () => number;
      getDimensions: () => number[];
  }

  export class WashingMachine implements IDevice {
      getCost = () => 140;
      getDimensions = () => [10, 15, 10, 15];
  }

  export class Fridge implements IDevice {
      getCost = () => 145;
      getDimensions = () => [10, 20, 10, 20];
  }

Now, lets say we have added a new type of device - Mobile. This device has few more characteristics like - screen resolution and camera resolution. It does not make sense to implement getScreenResolution and getCameraResolution methods in Fridge and WashingMachine. So how do we break this?


  export interface IDevice {
      getCost: () => number;
      getDimensions: () => number[];
      
      // additional methods
      getScreenResolution: () => number[];
      getCameraResolution: () => number;
  }

  export class Mobile implements IDevice {
      getCost = () => 145;
      getDimensions = () => [10, 20, 10, 20];
      getScreenResolution = () => [1900, 1200];
      getCameraResolution = () => 0.5;
  }

Instead of polluting our original IDevice interface and adding new methods which all of its clients does not need we can create a new interface extending it. This way we're not only using existing interface methods but also extending existing code.


  export interface IDevice {
      getCost: () => number;
      getDimensions: () => number[];
  }

  export interface MobileDevice extends IDevice {
      getScreenResolution: () => number[];
      getCameraResolution: () => number;
  }

Dependency Inversion Principle:

As per Interface Segregation Principle:

Entities must depend on abstractions, not on concretions.

What this means is if a module depends on another module/class the dependant module shouln't affected if internal structure/implementation of the depending module - as long as the abstraction interface is not changed.

Lets take an example of UserService class which needs to make some changes to database on call of its methods for example - create. Looks fine, However what if instead of using mongodb we've decided to use PostgreSQL database? We'll have to go the service and change the whole service.


  // user-service.ts
  import mongo from 'mongo';

  export class UserService {
    private mongoDbInstance;
    
    constructor() {
        this.mongoDbInstance = mongo.connect();
    }
    public create(data: unknown) {
        this.mongoDbInstance.users.create(data);
    }
  }

Instead of changing the original UserService if we add an intermediate layer where abstract away the database logic we no longer have to modify the user classes. Imagine how much work this would save use when doing the same thing for 100s of classes in real life.


  // user-service.ts
  import DatabaseProvider from 'database-provider';

  export class UserService {
      private databaseProvider;
      
      constructor() {
          this.databaseProvider = new DatabaseProvider();
      }

      public create(data: unknown) {
          this.databaseProvider.create(data);
      }
  }

  // database-provider.ts
  import pg from 'pg';

  export class DatabaseProvider {
      public create(data: object) {
          pg.create(data);
      }
  }

Conclusion:

We have seen the S.O.L.I.D principles if interpreted correctly can improve the code in a huge extent and also keep it clean, maintainable and easier to read. These principles transmit good ways of organizing the code thought these are often linked to OOP programming languages its good the to have these followed in any type of programming language.