DEV Community

Cover image for Applying SOLID Principles in NestJS
amir fakoor
amir fakoor

Posted on

Applying SOLID Principles in NestJS

Introduction

In the world of software development, writing clean, maintainable, scalable code is of utmost importance. One way to achieve this is by following the SOLID principles, a set of five design principles that help developers create robust and flexible software systems.

In this article, we will explore how to apply the SOLID principles in the context of Nest, a popular framework for building scalable and modular applications with TypeScript. We will dive into each principle, providing concise code examples to illustrate their implementation in a NestJS project. the end of this guide, you will have a understanding of how to leverage these principles to write cleaner and more maintainable code in your NestJS applications. So, let's get started and level up our NestJS development with SOLID principles!

The SOLID principles we will cover in this article are:

  1. Single Responsibility Principle (SRP)
  2. Open-Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

1. Single Responsibility Principle (SRP):

The Single Responsibility Principle, as the name suggests, proposes that each software module or class should have one specific role or responsibility. This principle is about cohesion in classes, and it aims to make the software design more understandable, flexible, and maintainable.

Let's consider an example in the context of a NestJS application:

// Before applying SRP
import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  createUser() {
    // code to create a user
  }

  deleteUser() {
    // code to delete a user
  }

  sendEmail() {
    // code to send an email
  }
}
Enter fullscreen mode Exit fullscreen mode

In the above example, the UserService class is handling user management and email sending, which violates the Single Responsibility Principle.

To adhere to the SRP, we can refactor the code as follows:

// After applying SRP
import { Injectable } from '@nestjs/common';

@Injectable()
export class UserService {
  createUser() {
    // code to create a user
  }

  deleteUser() {
    // code to delete a user
  }
}

@Injectable()
export class EmailService {
  sendEmail() {
    // code to send an email
  }
}

Enter fullscreen mode Exit fullscreen mode

Now, we have two separate classes, each handling a single responsibility. The UserService is responsible for user management, and the EmailService is responsible for sending emails. This makes our code more maintainable and easier to understand.

2. Open-Closed Principle (OCP):
The Open-Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. This means that a class should be easily extendable without modifying the class itself.

Let's illustrate this principle with a NestJS example:

Before applying OCP:

// greeter.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class GreeterService {
  greeting(type: string) {
    if (type === 'formal') {
      return 'Good day to you.';
    } else if (type === 'casual') {
      return 'Hey!';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In the above example, if we want to add a new type of greeting, we would have to modify the greeting method in the GreeterService class, which violates the Open-Closed Principle.

To adhere to the OCP, we can refactor the code as follows:

// greeting.interface.ts
export interface Greeting {
  greet(): string;
}

Enter fullscreen mode Exit fullscreen mode
// formalGreeting.ts
import { Greeting } from './greeting.interface';

export class FormalGreeting implements Greeting {
  greet() {
    return 'Good day to you.';
  }
}

Enter fullscreen mode Exit fullscreen mode
// casualGreeting.ts
import { Greeting } from './greeting.interface';

export class CasualGreeting implements Greeting {
  greet() {
    return 'Hey!';
  }
}
Enter fullscreen mode Exit fullscreen mode
// greeter.service.ts
import { Injectable } from '@nestjs/common';
import { Greeting } from './greeting.interface';

@Injectable()
export class GreeterService {
  greeting(greeting: Greeting) {
    return greeting.greet();
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, each class and interface is defined in its own file, making the code more organized and easier to manage. If we want add a new type of greeting, we can simply create a implementing the Greeting interface, without modifying the existing classes. This adheres to the Open-Closed Principle.

3. Liskov Substitution Principle (LSP):
The Liskov Substitution Principle (LSP) is a concept in Object Oriented Programming that states that in a program, objects of a superclass shall be able to be replaced with objects of a subclass without affecting the correctness of the program. It is about ensuring that a subclass can stand in for its parent class without breaking the functionality of your program.

Let's illustrate this principle with a NestJS example:

class Bird {
  fly(speed: number): string {
    return `Flying at ${speed} km/h`;
  }
}

class Eagle extends Bird {
  dive(): void {
    // ...
  }

  fly(speed: number): string {
    return `Soaring through the sky at ${speed} km/h`;
  }
}

// LSP Violation:
class Penguin extends Bird {
  fly(): never {
    throw new Error("Sorry, I can't fly");
  }
}
Enter fullscreen mode Exit fullscreen mode

In the above example, the Eagle class, which inherits from the Bird class, overrides the fly method with the same number of arguments, adhering to the Liskov Substitution Principle. However, the Penguin class violates the Liskov Substitution Principle because it can't fly, and thus, the fly method throws an error. This means that a Penguin object can't be substituted for a Bird object without altering the correctness of the program.

To adhere to the Liskov Substitution Principle, we need to ensure that subclasses do not alter the behavior of the parent class in such a way that could break the functionality of our program. In the case of our Bird, Eagle, and Penguin example, we could refactor the code to remove the fly method from the Bird class and instead use interfaces to define the capabilities of different types of birds.

interface FlyingBird {
  fly(speed: number): string;
}

interface NonFlyingBird {
  waddle(speed: number): string;
}

Enter fullscreen mode Exit fullscreen mode

Next, we define our Eagle and Penguin classes implementing these interfaces:

class Eagle implements FlyingBird {
  fly(speed: number): string {
    return `Soaring through the sky at ${speed} km/h`;
  }
}

class Penguin implements NonFlyingBird {
  waddle(speed: number): string {
    return `Waddling at ${speed} km/h`;
  }
}

Enter fullscreen mode Exit fullscreen mode

Now, we have two separate classes for Eagle and Penguin that implement different interfaces based on their abilities. This way, we are not violating the Liskov Substitution Principle because we are not pretending that a Penguin can fly. Instead, we are clearly defining what each type of bird can do and treating them differently based on their capabilities.

This approach also makes our code more flexible and easier to maintain. If we need to add a new type of bird in the future, we can simply create a new class for it and implement the appropriate interface based on its abilities.

4. Interface Segregation Principle (ISP):
The Interface Segregation Principle (ISP) states that no client should be forced to depend on methods it does not use. In other words, clients should not be forced to implement interfaces they do not use. This principle promotes the creation of fine-grained, client-specific interfaces.

The objective behind this principle is to remove unnecessary code from classes to reduce unexpected bugs when the class does not have the ability to perform an action. ISP encourages smaller, more targeted interfaces. According to this concept, multiple client-specific interfaces are preferable to a single general-purpose interface.

Let's illustrate this principle with a TypeScript example:

interface FullFeatureUser {
  viewAd(): void;
  skipAd(): void;
  startParty(): void;
}

class User {
  viewAd(): void {
    // ...
  }
}

class FreeUser extends User implements FullFeatureUser {
  skipAd(): void {
    throw new Error("Sorry, I can't skip ads");
  }

  startParty(): void {
    throw new Error("Sorry, I can't start parties");
  }
}

class PremiumUser extends User implements FullFeatureUser {
  skipAd(): void {
    // ...
  }

  startParty(): void {
    // ...
  }
}

Enter fullscreen mode Exit fullscreen mode

In the above example, the FreeUser class is forced to implement the skipAd and startParty methods even though it doesn't use them. This is a violation of the Interface Segregation Principle. To adhere to the ISP, we can create more specific interfaces:

interface User {
  viewAd(): void;
}

interface PremiumFeatureUser {
  skipAd(): void;
  startParty(): void;
}

class FreeUser implements User {
  viewAd(): void {
    // ...
  }
}

class PremiumUser implements User, PremiumFeatureUser {
  viewAd(): void {
    // ...
  }

  skipAd(): void {
    // ...
  }

  startParty(): void {
    // ...
  }
}

Enter fullscreen mode Exit fullscreen mode

With these changes, each class only implements the methods that it uses, adhering to the Interface Segregation Principle. This approach reduces the risk of bugs and makes our code more flexible and easier to maintain.

5. Dependency Inversion Principle (DIP):
The Dependency Inversion Principle (DIP) is the final principle in the SOLID design methodology. It states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. Abstractions should not rely on details. Details should depend on abstractions.

The Dependency Inversion Principle is a design principle that helps to decouple software modules. This principle plays a vital role in controlling the coupling between different modules of a program.

Let's illustrate this principle with a TypeScript example:

class MySQLDatabase {
  save(data: string): void {
    // Save data to MySQL database
  }
}

class UserService {
  private database: MySQLDatabase;

  constructor(database: MySQLDatabase) {
    this.database = database;
  }

  saveUser(user: string): void {
    this.database.save(user);
  }
}

Enter fullscreen mode Exit fullscreen mode

In the above example, the UserService class is tightly coupled with the MySQLDatabase class. This means that if you want to change the database system (like switching from MySQL to MongoDB), you would need to change the UserService class as well. This is a violation of the Dependency Inversion Principle.

To adhere to DIP, we can introduce an abstraction (interface) between the UserService and MySQLDatabase classes:

interface Database {
  save(data: string): void;
}

class MySQLDatabase implements Database {
  save(data: string): void {
    // Save data to MySQL database
  }
}

class UserService {
  private database: Database;

  constructor(database: Database) {
    this.database = database;
  }

  saveUser(user: string): void {
    this.database.save(user);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, the UserService class depends on the Database interface, not on the concrete MySQLDatabase class. This means that you can easily switch to a different database system by creating a new class that implements the Database interface. This approach makes your code more flexible and easier to maintain.

Conclusion:

The SOLID principles are a set of design principles that help developers write clean, maintainable, and scalable code. By following these principles, you can create software systems that are easy to understand, flexible, and robust.

By understanding and applying these principles, you can your NestJS development skills and write cleaner and more maintainable code in your applications. Happy coding!

Top comments (4)

Collapse
 
hsn0najafi profile image
Hossein Najafi

Thank you so much for this article. 🙏

I think it would be much easier to understand to show in the DIP example, how you change different database repositories in UserModule.

Like this:
docs.nestjs.com/fundamentals/custo...

  • Sorry for my bad grammar ...
Collapse
 
abaikov profile image
Andrey Baikov

It doesn't explain how to deal with relations

Collapse
 
redrosh profile image
RedRosh • Edited

great article. Can you recommend books that will go in depth for each principles with some real examples ?

Collapse
 
amirfakour profile image
amir fakoor

guy, I'm not much into learning from books. There's loads of stuff on YouTube, dev.to, Medium. Plus, now we also have access to ChatGPT.

Just dive in, learn as you go, and apply it to your project.