NestJS is a powerful framework for building efficient and scalable server-side applications. Leveraging TypeScript and strong design patterns, it encourages developers to write clean, maintainable, and testable code. One way to achieve this is by applying SOLID principles.
What are the SOLID Principles in NestJS?
SOLID is an acronym for five design principles intended to make software designs more understandable, flexible, and maintainable:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Let’s deep dive into each principle and see how to apply them in a NestJS application.
Example
We’ll build a system to manage records related to drugs and countries, applying SOLID principles to ensure our code is clean and maintainable.
1. Single Responsibility Principle (SRP)
Each class should have only one reason to change, meaning each class should only have one job or responsibility.
Step 1: Define the Abstract Service
First, we create an abstract class to define the contract for our services:
import { Injectable } from '@nestjs/common';
import { CreateRecordDto } from './dto/create-record.dto';
import { UpdateRecordDto } from './dto/update-record.dto';
@Injectable()
export abstract class AbstractRecordsService {
abstract create(createRecordDto: CreateRecordDto);
abstract findAll();
abstract findOne(id: number);
abstract update(id: number, updateRecordDto: UpdateRecordDto);
abstract remove(id: number);
}
This abstract class ensures that any concrete implementation will follow the same contract, promoting consistency and adherence to SRP.
2. Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.
Step 2: Create Concrete Services
We’ll implement two services: DrugService
and CountryService
.
import { Injectable } from '@nestjs/common';
import { CreateRecordDto } from './dto/create-record.dto';
import { UpdateRecordDto } from './dto/update-record.dto';
import { AbstractRecordsService } from './abstract-records.service';
@Injectable()
export class DrugService implements AbstractRecordsService {
create(createRecordDto: CreateRecordDto) {
console.log(createRecordDto);
return 'This action adds a new drug record';
}
findAll() {
return `This action returns all drug records`;
}
findOne(id: number) {
return `This action returns a #${id} drug record`;
}
update(id: number, updateRecordDto: UpdateRecordDto) {
console.log(updateRecordDto);
return `This action updates a #${id} drug record`;
}
remove(id: number) {
return `This action removes a #${id} drug record`;
}
}
Similarly, we define the CountryService
:
@Injectable()
export class CountryService implements AbstractRecordsService {
private readonly records = [];
create(createRecordDto: CreateRecordDto) {
const newRecord = { id: Date.now(), ...createRecordDto };
this.records.push(newRecord);
return newRecord;
}
findAll() {
return this.records;
}
findOne(id: number) {
const record = this.records.find(record => record.id === id);
if (!record) {
throw new Error(`Record with ID ${id} not found`);
}
return record;
}
update(id: number, updateRecordDto: UpdateRecordDto) {
const recordIndex = this.records.findIndex(record => record.id === id);
if (recordIndex === -1) {
throw new Error(`Record with ID ${id} not found`);
}
const updatedRecord = { ...this.records[recordIndex], ...updateRecordDto };
this.records[recordIndex] = updatedRecord;
return updatedRecord;
}
remove(id: number) {
const recordIndex = this.records.findIndex(record => record.id === id);
if (recordIndex === -1) {
throw new Error(`Record with ID ${id} not found`);
}
const removedRecord = this.records.splice(recordIndex, 1);
return removedRecord[0];
}
}
3. Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering the correctness of the program. Our abstract service ensures that DrugService
and CountryService
can be used interchangeably.
Step 3: Utilize the Factory Pattern
We use a factory to determine which service to inject based on the query parameter.
import { Injectable } from '@nestjs/common';
import { DrugService } from './drug.service';
import { CountryService } from './country.service';
@Injectable()
export class RecordsServiceFactory {
constructor(
private readonly drugService: DrugService,
private readonly countryService: CountryService,
) {}
getService(type: string): AbstractRecordsService {
if (type === 'drug') {
return this.drugService;
} else if (type === 'country') {
return this.countryService;
}
throw new Error('Invalid service type');
}
}
4. Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use. In our case, instead of having a single AbstractRecordsService
with all possible methods, we can divide it into more granular interfaces. This ensures that each service only implements the methods it requires.
For instance, instead of having
export abstract class AbstractRecordsService {
abstract create(createRecordDto: CreateRecordDto);
abstract findAll();
abstract findOne(id: number);
abstract update(id: number, updateRecordDto: UpdateRecordDto);
abstract remove(id: number);
}
We can divide the AbstractRecordsService
into more specific interfaces:
export interface ICreateRecord {
create(createRecordDto: CreateRecordDto);
}
export interface IFindAllRecords {
findAll();
}
export interface IFindOneRecord {
findOne(id: number);
}
export interface IUpdateRecord {
update(id: number, updateRecordDto: UpdateRecordDto);
}
export interface IRemoveRecord {
remove(id: number);
}
Concrete services will now only implement the interfaces they actually use:
import { Injectable } from '@nestjs/common';
import { ICreateRecord, IFindAllRecords, IFindOneRecord, IUpdateRecord, IRemoveRecord } from './interfaces';
import { CreateRecordDto } from './dto/create-record.dto';
import { UpdateRecordDto } from './dto/update-record.dto';
@Injectable()
export class DrugService implements ICreateRecord, IFindAllRecords, IFindOneRecord, IUpdateRecord, IRemoveRecord {
// Implementation of the methods
}
5. Dependency Inversion Principle (DIP)
Depend on abstractions, not on concretions. By using the AbstractRecordsService
, we ensure that high-level modules are not dependent on low-level modules but on abstractions.
Step 4: Update the Controller
We update the controller to use the factory service to get the appropriate service based on the query parameter.
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
} from '@nestjs/common';
import { RecordsServiceFactory } from './records-service.factory';
import { CreateRecordDto } from './dto/create-record.dto';
import { UpdateRecordDto } from './dto/update-record.dto';
@Controller('records')
export class RecordsController {
constructor(private readonly recordsServiceFactory: RecordsServiceFactory) {}
@Post()
create(@Query('type') type: string, @Body() createRecordDto: CreateRecordDto) {
const service = this.recordsServiceFactory.getService(type);
return service.create(createRecordDto);
}
@Get()
findAll(@Query('type') type: string) {
const service = this.recordsServiceFactory.getService(type);
return service.findAll();
}
@Get(':id')
findOne(@Param('id') id: string, @Query('type') type: string) {
const service = this.recordsServiceFactory.getService(type);
return service.findOne(+id);
}
@Patch(':id')
update(@Param('id') id: string, @Query('type') type: string, @Body() updateRecordDto: UpdateRecordDto) {
const service = this.recordsServiceFactory.getService(type);
return service.update(+id, updateRecordDto);
}
@Delete(':id')
remove(@Param('id') id: string, @Query('type') type: string) {
const service = this.recordsServiceFactory.getService(type);
return service.remove(+id);
}
}
Step 5: Register Services and Controller in the Module
Ensure that all services and the controller are registered in the module.
import { Module } from '@nestjs/common';
import { RecordsController } from './records.controller';
import { DrugService } from './drug.service';
import { CountryService } from './country.service';
import { RecordsServiceFactory } from './records-service.factory';
@Module({
controllers: [RecordsController],
providers: [DrugService, CountryService, RecordsServiceFactory],
})
export class RecordsModule {}
By applying SOLID principles in our NestJS application, we’ve created a clean, maintainable, and scalable architecture. Each principle plays a crucial role in ensuring our code is robust and easy to manage.
Leave a Reply