Implementing SOLID Principles in NestJS Backends
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Introduction
In the ever-evolving landscape of software development, building robust, maintainable, and scalable systems is paramount. As JavaScript continues its reign, particularly with the advent of TypeScript bringing static typing and enhanced predictability, backend development has seen significant advancements. Frameworks like NestJS, with its opinionated and modular architecture, provide a fantastic foundation for building enterprise-grade applications. However, simply using a powerful framework isn't enough; true quality comes from adhering to fundamental design principles. This is where SOLID principles come into play. These five principles – Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion – offer a roadmap for writing cleaner, more flexible, and resilient code. In a TypeScript backend project, especially one leveraging NestJS, understanding and applying SOLID principles can drastically improve code organization, reduce technical debt, and facilitate collaboration. This article will explore each SOLID principle in detail, demonstrating its practical application within a NestJS context through concrete code examples.
Core Concepts Explained
Before diving into the practical implementations, let's briefly define the core SOLID principles:
- Single Responsibility Principle (SRP): A class or module should have only one reason to change. This means it should be responsible for a single, well-defined piece of functionality.
- Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. You should be able to add new functionality without altering existing, working code.
- Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without altering the correctness of the program. This means that if
Sis a subtype ofT, then objects of typeTcan be replaced with objects of typeSwithout breaking the application. - Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. Instead of one large, general-purpose interface, prefer many smaller, role-specific interfaces.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.
NestJS, with its strong emphasis on modules, services, and dependency injection, provides an excellent environment for practicing these principles.
Implementing SOLID Principles in NestJS
Let's illustrate these principles with practical examples within a NestJS application, considering a typical e-commerce scenario.
Single Responsibility Principle (SRP)
In NestJS, controllers often orchestrate requests, services handle business logic, and repositories manage data access. Adhering to SRP means separating these concerns clearly.
Bad Example (Violates SRP):
// user.controller.ts (Illustrative - does too much) import { Controller, Get, Post, Body } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './user.entity'; @Controller('users') export class UserController { constructor( @InjectRepository(User) private usersRepository: Repository<User>, ) {} @Post() async createUser(@Body() userData: any): Promise<User> { // Logic for creating user, hashing password, sending welcome email, etc. const newUser = this.usersRepository.create(userData); await this.usersRepository.save(newUser); // Imagine sending email logic here console.log('Sending welcome email...'); return newUser; } @Get(':id') async getUser(@Param('id') id: string): Promise<User> { return this.usersRepository.findOne(id); } }
This UserController is responsible for handling HTTP requests, creating users, saving them to the database, and potentially sending emails. If any of these concerns change, the UserController would need modification.
Good Example (Adheres to SRP):
// user.entity.ts import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column({ unique: true }) email: string; @Column() passwordHash: string; // Storing hashed password } // user.repository.ts (Handles data access for User) import { EntityRepository, Repository } from 'typeorm'; import { User } from './user.entity'; @EntityRepository(User) export class UserRepository extends Repository<User> { // Custom data access methods can go here } // email.service.ts (Handles email sending concern) import { Injectable } from '@nestjs/common'; @Injectable() export class EmailService { async sendWelcomeEmail(to: string, username: string): Promise<void> { console.log(`Sending welcome email to ${username} at ${to}`); // Actual email sending logic here (e.g., using nodemailer) } } // user.service.ts (Handles business logic for Users) import { Injectable } from '@nestjs/common'; import { UserRepository } from './user.repository'; import { EmailService } from '../email/email.service'; import * as bcrypt from 'bcrypt'; // For password hashing @Injectable() export class UserService { constructor( private readonly userRepository: UserRepository, private readonly emailService: EmailService, ) {} async createUser(email: string, passwordPlain: string): Promise<User> { const passwordHash = await bcrypt.hash(passwordPlain, 10); const newUser = this.userRepository.create({ email, passwordHash }); await this.userRepository.save(newUser); await this.emailService.sendWelcomeEmail(email, email); // Using email as username for simplicity return newUser; } async findUserById(id: number): Promise<User> { return this.userRepository.findOne(id); } } // user.controller.ts (Handles HTTP requests, delegates to service) import { Controller, Get, Post, Body, Param } from '@nestjs/common'; import { UserService } from './user.service'; import { User } from './user.entity'; @Controller('users') export class UserController { constructor(private readonly userService: UserService) {} @Post() async createUser(@Body() userData: { email: string; passwordPlain: string }): Promise<User> { return this.userService.createUser(userData.email, userData.passwordPlain); } @Get(':id') async getUser(@Param('id') id: string): Promise<User> { return this.userService.findUserById(parseInt(id, 10)); } }
Now, UserController only handles HTTP requests, UserService manages user-related business logic, UserRepository handles data persistence, and EmailService is solely responsible for sending emails. Each class has a single reason to change.
Open/Closed Principle (OCP)
To illustrate OCP, consider a scenario where you have different types of payment methods in an e-commerce application.
Bad Example (Violates OCP):
// payment.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class PaymentService { processPayment(amount: number, method: 'credit_card' | 'paypal' | 'stripe'): string { if (method === 'credit_card') { // Logic for credit card payment return `Processed credit card payment of $${amount}`; } else if (method === 'paypal') { // Logic for PayPal payment return `Processed PayPal payment of $${amount}`; } else if (method === 'stripe') { // Logic for Stripe payment return `Processed Stripe payment of $${amount}`; } else { throw new Error('Unsupported payment method'); } } }
Adding a new payment method (e.g., "Google Pay") would require modifying the processPayment method, violating OCP.
Good Example (Adheres to OCP):
// payment-method.interface.ts export interface IPaymentMethod { processPayment(amount: number): string; } // credit-card.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class CreditCardPayment implements IPaymentMethod { processPayment(amount: number): string { // Credit card specific logic return `Processed Credit Card payment of $${amount}`; } } // paypal.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class PayPalPayment implements IPaymentMethod { processPayment(amount: number): string { // PayPal specific logic return `Processed PayPal payment of $${amount}`; } } // stripe.payment.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class StripePayment implements IPaymentMethod { processPayment(amount: number): string { // Stripe specific logic return `Processed Stripe payment of $${amount}`; } } // payment.service.ts import { Injectable } from '@nestjs/common'; import { IPaymentMethod } from './payment-method.interface'; @Injectable() export class PaymentService { // NestJS Dependency Injection allows us to inject concrete implementations // based on a token or provided in a module. // We can also have a factory or strategy pattern here. processPaymentWithStrategy(paymentMethod: IPaymentMethod, amount: number): string { return paymentMethod.processPayment(amount); } } // payment.module.ts (Example of how to provide different strategies) import { Module } from '@nestjs/common'; import { PaymentService } from './payment.service'; import { CreditCardPayment } from './credit-card.payment'; import { PayPalPayment } from './paypal.payment'; import { StripePayment } from './stripe.payment'; import { IPaymentMethod } from './payment-method.interface'; const paymentMethodProviders = [ { provide: 'CREDIT_CARD_PAYMENT', useClass: CreditCardPayment, }, { provide: 'PAYPAL_PAYMENT', useClass: PayPalPayment, }, { provide: 'STRIPE_PAYMENT', useClass: StripePayment, }, ]; @Module({ providers: [PaymentService, ...paymentMethodProviders], exports: [PaymentService, ...paymentMethodProviders], }) export class PaymentModule {} // payment.controller.ts import { Controller, Post, Body, Inject } from '@nestjs/common'; import { PaymentService } from './payment.service'; import { IPaymentMethod } from './payment-method.interface'; @Controller('payments') export class PaymentController { constructor( private readonly paymentService: PaymentService, @Inject('CREDIT_CARD_PAYMENT') private readonly creditCardPayment: IPaymentMethod, @Inject('PAYPAL_PAYMENT') private readonly payPalPayment: IPaymentMethod, @Inject('STRIPE_PAYMENT') private readonly stripePayment: IPaymentMethod, ) {} @Post('process') processPayment(@Body() data: { amount: number; method: string }): string { let paymentStrategy: IPaymentMethod; switch (data.method) { case 'credit_card': paymentStrategy = this.creditCardPayment; break; case 'paypal': paymentStrategy = this.payPalPayment; break; case 'stripe': paymentStrategy = this.stripePayment; break; default: throw new Error('Unsupported payment method'); } return this.paymentService.processPaymentWithStrategy(paymentStrategy, data.amount); } }
Now, to add a new payment method, you simply create a new class implementing IPaymentMethod and register it in the module. The existing PaymentService and PaymentController remain unchanged, adhering to OCP.
Liskov Substitution Principle (LSP)
LSP ensures that new derived classes extend the base class without changing its behavior. Consider a Product entity and its subtypes.
Bad Example (Violates LSP):
// product.interface.ts export interface IProduct { getId(): string; getPrice(): number; // A method that applies a discount for all products applyDiscount(percentage: number): number; } // tangible-product.class.ts export class TangibleProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } applyDiscount(percentage: number): number { return this.price * (1 - percentage / 100); } } // digital-product.class.ts (LSP Violation) export class DigitalProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } // Digital products cannot have discounts applied due to licensing or fixed costs applyDiscount(percentage: number): number { throw new Error('Digital products cannot be discounted.'); } } // Somewhere in the order processing logic function calculateTotal(products: IProduct[]): number { let total = 0; for (const product of products) { // This function expects applyDiscount to work for all products // If a DigitalProduct is passed, it will throw an error, breaking the contract. total += product.applyDiscount(10); // Applying a 10% discount } return total; }
Here, DigitalProduct's applyDiscount method throws an error, changing the expected behavior of IProduct.applyDiscount. If a DigitalProduct is used where an IProduct is expected, the program might crash.
Good Example (Adheres to LSP):
To fix this, we can either re-evaluate the interface or make DigitalProduct handle the discount gracefully (e.g., return original price). A better approach for LSP might be to separate concerns or define behaviors more precisely.
// discountable-product.interface.ts export interface IDiscountableProduct { applyDiscount(percentage: number): number; } // product.interface.ts (Base product without discount logic) export interface IProduct { getId(): string; getPrice(): number; } // tangible-product.class.ts export class TangibleProduct implements IProduct, IDiscountableProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } applyDiscount(percentage: number): number { return this.price * (1 - percentage / 100); } } // digital-product.class.ts (No discount functionality by default) export class DigitalProduct implements IProduct { constructor(private id: string, private price: number) {} getId(): string { return this.id; } getPrice(): number { return this.price; } // No applyDiscount method, or it returns the original price } // Somewhere in the order processing logic function calculateDiscountedTotal(products: IDiscountableProduct[]): number { let total = 0; for (const product of products) { total += product.applyDiscount(10); // Now it's safe as only discountable products are passed } return total; } function calculateTotal(products: IProduct[]): number { let total = 0; for (const product of products) { total += product.getPrice(); } return total; }
Now, functions that expect discountable products will only receive them, ensuring that the behavior doesn't change unexpectedly. This also touches upon ISP.
Interface Segregation Principle (ISP)
ISP suggests that clients should not be forced to depend on interfaces they do not use.
Bad Example (Violates ISP):
// user-management.interface.ts (Fat Interface) export interface IUserManager { createUser(data: any): Promise<any>; updateUser(id: string, data: any): Promise<any>; deleteUser(id: string): Promise<void>; getUsers(): Promise<any[]>; resetPassword(userId: string): Promise<void>; assignRole(userId: string, role: string): Promise<void>; // ... and other unrelated methods like sendWelcomeEmail, generateReport, etc. } // A service that only needs to create and get users, but implements the whole interface export class UserDisplayService implements IUserManager { createUser(data: any): Promise<any> { /* ... */ } updateUser(id: string, data: any): Promise<any> { throw new Error('Not implemented'); } // Not needed deleteUser(id: string): Promise<void> { throw new Error('Not implemented'); } // Not needed getUsers(): Promise<any[]> { /* ... */ } resetPassword(userId: string): Promise<void> { throw new Error('Not implemented'); } // Not needed assignRole(userId: string, role: string): Promise<void> { throw new Error('Not implemented'); } // Not needed }
UserDisplayService is forced to implement methods it doesn't need, leading to unnecessary complexity and potential for errors (e.g., throwing Not implemented errors).
Good Example (Adheres to ISP):
// user-creator.interface.ts export interface IUserCreator { createUser(data: any): Promise<any>; } // user-reader.interface.ts export interface IUserReader { getUsers(): Promise<any[]>; getUserById(id: string): Promise<any>; } // user-updater.interface.ts export interface IUserUpdater { updateUser(id: string, data: any): Promise<any>; resetPassword(userId: string): Promise<void>; } // user-deleter.interface.ts export interface IUserDeleter { deleteUser(id: string): Promise<void>; } // A service for displaying users only needs IuserReader export class UserDisplayService implements IUserReader { constructor(/* dependencies */) {} getUsers(): Promise<any[]> { /* ... */ } getUserById(id: string): Promise<any> { /* ... */ } } // A service for full user management might implement multiple smaller interfaces export class FullUserService implements IUserCreator, IUserReader, IUserUpdater, IUserDeleter { constructor(/* dependencies */) {} createUser(data: any): Promise<any> { /* ... */ } getUsers(): Promise<any[]> { /* ... */ } getUserById(id: string): Promise<any> { /* ... */ } updateUser(id: string, data: any): Promise<any> { /* ... */ } resetPassword(userId: string): Promise<void> { /* ... */ } deleteUser(id: string): Promise<void> { /* ... */ } }
Now, UserDisplayService only depends on IUserReader, eliminating the need to implement unused methods. This makes the code cleaner, more focused, and easier to test.
Dependency Inversion Principle (DIP)
DIP emphasizes depending on abstractions rather than concrete implementations. NestJS's dependency injection container strongly supports DIP.
Bad Example (Violates DIP):
// email-sender.class.ts (Concrete low-level module) export class SMSEmailSender { send(to: string, subject: string, body: string): void { console.log(`Sending SMS email to ${to}: ${subject} - ${body}`); // Simulate sending via a specific SMS-to-email gateway } } // user.service.ts import { Injectable } from '@nestjs/common'; import { SMSEmailSender } from './email-sender.class'; // Direct dependency on concrete class @Injectable() export class UserService { private emailSender: SMSEmailSender; constructor() { this.emailSender = new SMSEmailSender(); // High-level module creates low-level module } async registerUser(email: string, passwordHash: string): Promise<void> { // ... user registration logic ... this.emailSender.send(email, 'Welcome!', 'Thank you for registering.'); } }
UserService (high-level module) directly depends on SMSEmailSender (low-level module). If we want to switch to a different email sending mechanism (e.g., using SendGrid or Mailgun), UserService needs to be modified.
Good Example (Adheres to DIP):
// email-sender.interface.ts (Abstraction) export interface IEmailSender { send(to: string, subject: string, body: string): Promise<void>; } // sms-email-sender.class.ts (Concrete low-level module, depends on abstraction) import { Injectable } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class SMSEmailSender implements IEmailSender { async send(to: string, subject: string, body: string): Promise<void> { console.log(`Sending SMS email to ${to}: ${subject} - ${body}`); // Actual SMS-to-email gateway integration } } // sendgrid-email-sender.class.ts (Another concrete low-level module) import { Injectable } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class SendGridEmailSender implements IEmailSender { async send(to: string, subject: string, body: string): Promise<void> { console.log(`Sending via SendGrid to ${to}: ${subject} - ${body}`); // Actual SendGrid API integration } } // user.service.ts (High-level module, depends on abstraction) import { Injectable, Inject } from '@nestjs/common'; import { IEmailSender } from './email-sender.interface'; @Injectable() export class UserService { constructor( @Inject('EMAIL_SENDER') private readonly emailSender: IEmailSender, // Depends on abstraction ) {} async registerUser(email: string, passwordHash: string): Promise<void> { // ... user registration logic ... await this.emailSender.send(email, 'Welcome!', 'Thank you for registering.'); } } // user.module.ts (Wiring up dependencies using NestJS DI) import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { SMSEmailSender } from './sms-email-sender.class'; import { SendGridEmailSender } from './sendgrid-email-sender.class'; import { IEmailSender } from './email-sender.interface'; @Module({ providers: [ UserService, // Choose which implementation to provide based on feature toggles, environment, etc. { provide: 'EMAIL_SENDER', // Token for the dependency useClass: SMSEmailSender, // The concrete implementation to use // In a real project, you might switch this: // useClass: process.env.EMAIL_PROVIDER === 'SENDGRID' ? SendGridEmailSender : SMSEmailSender, }, ], exports: [UserService], }) export class UserModule {}
Now, UserService depends on the IEmailSender abstraction. The actual implementation (SMSEmailSender or SendGridEmailSender) is decided at the module level (or even at runtime/configuration), without requiring UserService to change. This provides excellent flexibility and testability.
Conclusion
Adopting SOLID principles in a TypeScript backend project with NestJS is not merely about following a set of rules; it's about cultivating a mindset that leads to the creation of flexible, robust, and maintainable software. From ensuring that each component has a clear, singular purpose (SRP) to designing systems that can easily incorporate new features without disturbing existing code (OCP), and from building reliable inheritance hierarchies (LSP) to crafting nimble and focused interfaces (ISP), and finally, to decoupling high-level policies from low-level details through abstractions (DIP), SOLID principles provide an invaluable framework. NestJS's architectural patterns – modules, services, repositories, and its powerful dependency injection system – naturally align with and facilitate the application of these principles, enabling developers to build complex, scalable applications with confidence and ease. By consciously applying SOLID principles throughout your development lifecycle, you elevate your codebase from functional to exemplary.