The S-O-L-I-D Principles in NestJS.

Martin Kariuki • 5 October 2024

When developing modern applications, adhering to design principles ensures scalability, maintainability, and flexibility. NestJS, a powerful framework built on Node.js, encourages the use of well-established programming principles. In this blog, we'll explore how to apply the SOLID principles to an image upload functionality to AWS S3 within a NestJS application.
What are SOLID Principles?
Before diving into the implementation, let's briefly recap the SOLID principles:
Single Responsibility Principle (SRP): A class should have only one reason to change, i.e., it should do only one thing.
Open/Closed Principle (OCP): Classes should be open for extension but closed for modification.
Liskov Substitution Principle (LSP): Objects of a superclass should be replaceable with objects of its subclasses without affecting correctness.
Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use.
Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.
Step 1: Setting Up AWS SDK and NestJS
- First, we need to set up AWS SDK for S3 image upload functionality.
npm install @aws-sdk/client-s3
npm install @nestjs/config
We'll also configure the AWS credentials in an environment file (.env):
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=your-region
AWS_BUCKET_NAME=your-bucket-name
Next, add configuration to NestJS:
Register ConfigModule into the app module
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
],
})
export class AppModule {}
Step 2: Applying the Single Responsibility Principle (SRP)
- We can start by creating a service whose sole responsibility is uploading images. This service will encapsulate the logic for handling AWS S3 interactions.
import { Injectable } from '@nestjs/common';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AwsS3Service {
private s3Client: S3Client;
constructor(private configService: ConfigService) {
this.s3Client = new S3Client({
region: this.configService.get<string>('AWS_REGION'),
credentials: {
accessKeyId: this.configService.get<string>('AWS_ACCESS_KEY_ID'),
secretAccessKey: this.configService.get<string>('AWS_SECRET_ACCESS_KEY'),
},
});
}
async uploadFile(file: Express.Multer.File): Promise<string> {
const bucketName = this.configService.get<string>('AWS_BUCKET_NAME');
const uploadResult = await this.s3Client.send(
new PutObjectCommand({
Bucket: bucketName,
Key: file.originalname,
Body: file.buffer,
ContentType: file.mimetype,
}),
);
return `https://${bucketName}.s3.amazonaws.com/${file.originalname}`;
}
}
\\ This service adheres to SRP as it focuses solely on handling AWS S3 operations.
Step 3: Open/Closed Principle (OCP)
- To apply OCP, we make sure that the core logic of uploading a file to S3 can be extended without modifying the existing code. For example, we might want to extend the functionality to upload files to another cloud provider, or to perform file validation before uploading.
One approach is to define an abstraction (interface) for file upload services:
export interface FileUploader {
uploadFile(file: Express.Multer.File): Promise<string>;
}
Now, our AwsS3Service can implement this interface:
@Injectable()
export class AwsS3Service implements FileUploader {
// Same as above, with the uploadFile implementation.
}
To add a new uploader, simply create a new service implementing the FileUploader interface, without modifying existing code:
@Injectable()
export class LocalFileUploaderService implements FileUploader {
async uploadFile(file: Express.Multer.File): Promise<string> {
// Logic to store the file locally
return `/uploads/${file.originalname}`;
}
}
Step 4: Liskov Substitution Principle (LSP)
By ensuring all upload services conform to the FileUploader interface, we can substitute the AwsS3Service with any other implementation without breaking the system.
@Injectable()
export class UploadService {
constructor(private fileUploader: FileUploader) {}
async upload(file: Express.Multer.File): Promise<string> {
return this.fileUploader.uploadFile(file);
}
}
- This makes it easy to inject any other uploader (like the LocalFileUploaderService) via dependency injection, maintaining the principle of substitutability.
Step 5: Interface Segregation Principle (ISP)
With ISP, we ensure that classes don't depend on methods they don't use. In our case, the FileUploader interface is lean and focuses only on the uploadFile method. It’s best to avoid adding extra methods like deleteFile, unless they are truly necessary for all implementations.
This prevents clients like UploadService from depending on methods they don’t need or use, keeping the codebase clean and focused.
Step 6: Dependency Inversion Principle (DIP)
Finally, we apply DIP by ensuring that both UploadService and the upload implementation (like AwsS3Service) depend on the FileUploader abstraction, rather than concrete classes.
We achieve this by providing the FileUploader via NestJS's dependency injection system:
@Module({
providers: [
{
provide: 'FileUploader',
useClass: AwsS3Service,
},
UploadService,
],
})
export class UploadModule {}
This makes it easy to swap out implementations of FileUploader without modifying high-level modules, in line with DIP.
Conclusion
-
By applying the SOLID principles, we’ve made our image upload functionality modular, maintainable, and extensible. We used the Single Responsibility Principle to isolate AWS S3 logic, the Open/Closed Principle to allow easy extension, the Liskov Substitution Principle to ensure interchangeable upload services, the Interface Segregation Principle to keep interfaces minimal, and the Dependency Inversion Principle to decouple high-level and low-level modules.
This approach not only keeps your codebase clean but also ensures it’s ready to scale as your application grows!