Applying SOLID Principles in NestJS
amir fakoor
Posted on July 3, 2023
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:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- 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
}
}
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
}
}
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!';
}
}
}
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;
}
// formalGreeting.ts
import { Greeting } from './greeting.interface';
export class FormalGreeting implements Greeting {
greet() {
return 'Good day to you.';
}
}
// casualGreeting.ts
import { Greeting } from './greeting.interface';
export class CasualGreeting implements Greeting {
greet() {
return 'Hey!';
}
}
// greeter.service.ts
import { Injectable } from '@nestjs/common';
import { Greeting } from './greeting.interface';
@Injectable()
export class GreeterService {
greeting(greeting: Greeting) {
return greeting.greet();
}
}
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");
}
}
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;
}
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`;
}
}
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 {
// ...
}
}
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 {
// ...
}
}
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);
}
}
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);
}
}
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!
Posted on July 3, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.