From Theory to Practice: Using SOLID in NestJS
Writing maintainable and scalable code is a challenge every developer faces. SOLID principles provide a structured way to keep your NestJS applications clean, modular, and easy to extend.
Reading time: ~15 minutes
Have you ever come back to a project after months and wondered 'who wrote this messy code...' only to find out it was you? Well, you're not alone. Technical debt is the silent enemy we all face, but there is a powerful shield: SOLID principles.
In this article, we'll explore not only what these principles are, but how to apply them in NestJS with practical examples and how to be aware when you are in the grip of complicated patterns. You'll have the necessary tools to start writing code that your future colleagues (and future you!) will thank you for.
Why SOLID and why NestJS?
Before diving into the details, let's clarify what SOLID stands for:
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
NestJS is designed with many SOLID principles in mind, using dependency injection and a modular system. However, we can use these principles to create really strong applications.
The SOLID principles are not strict rules, but more like guidelines to help you:
- Adapt your code to changing requirements painlessly
- Write unit tests with ease
- Reuse components in different parts of your application
- Scale your project without it becoming an unmanageable monster
Let's see how to implement them correctly.
S — Single Responsibility Principle (SRP)
There should never be more than one reason for a class to change — Robert C. Martin
Imagine your code is like a professional kitchen. Each chef has a specific task: one handles desserts, another prepares main courses and another manages appetizers. The problem happens when one chef tries to do all tasks—quality suffers and the kitchen becomes chaotic.
✅ Use this:
What makes this example good is the clear separation of concerns. Each service handles exactly one aspect of the system:
// Handles user CRUD operations only
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly passwordService: PasswordService,
) {}
async createUser(userDto: CreateUserDto) {
const hashedPassword = this.passwordService.hashPassword(userDto.password);
const userToSave = {
...userDto,
password: hashedPassword,
};
return this.userRepository.create(userToSave);
}
}
// Exclusively responsible for safety logic
@Injectable()
export class PasswordService {
async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
async validatePassword(plain: string, hashed: string): Promise<boolean> {
return bcrypt.compare(plain, hashed);
}
}
❌ Avoid this:
@Injectable()
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
async createUser(userDto: CreateUserDto) {
// Mixing responsibilities: persistence and security
const hashedPassword = await bcrypt.hash(userDto.password, 10);
userDto.password = hashedPassword;
// It also handles emailing—too many responsibilities!
await this.sendWelcomeEmail(userDto.email);
return this.userRepository.create(userDto);
}
private async sendWelcomeEmail(email: string) {
// Email logic that shouldn't be here
}
}
How can you spot when you're violating this principle? If you use the word 'and' when explaining what your class does, you are probably breaking the SRP rule. A service should do one thing, not a mix of things.
The real power of this principle shows when it's time to change how passwords are encrypted—you only need to change the PasswordService
and not the user logic. In one of my recent projects, this separation allowed us to upgrade our encryption method without touching any of the user management code, saving us hours of testing and potential bugs!
O — Open/Closed Principle (OCP)
Software entities should be open for extension, but closed for modification.
Now that we understand how to give our classes focused responsibilities, let's see how to make them adaptable to change. Imagine your code is like a house with different rooms. You shouldn't be moving walls every time you want to add a new feature—instead, you should have modular furniture that can be rearranged.
✅ Extendable design:
What makes this example powerful is how new payment methods can be added without changing existing code:
// Define an abstraction for payment strategies
export interface PaymentStrategy {
pay(orderId: string, amount: number): Promise<PaymentResult>;
getName(): string;
}
@Injectable()
export class StripePaymentStrategy implements PaymentStrategy {
async pay(orderId: string, amount: number): Promise<PaymentResult> {
// Stripe specific implementation
return { status: 'success', transactionId: 'stripe-123' };
}
getName(): string {
return 'stripe';
}
}
@Injectable()
export class PayPalPaymentStrategy implements PaymentStrategy {
async pay(orderId: string, amount: number): Promise<PaymentResult> {
// Specific implementation of PayPal
return { status: 'success', transactionId: 'pp-456' };
}
getName(): string {
return 'paypal';
}
}
export const PAYMENT_STRATEGIES = Symbol('PAYMENT_STRATEGIES');
// Implementation in a NestJS module
@Module({
providers: [
{
provide: PAYMENT_STRATEGIES,
useFactory: (...strategies) => strategies,
inject: [StripePaymentStrategy, PayPalPaymentStrategy],
},
PaymentService,
StripePaymentStrategy,
PayPalPaymentStrategy,
],
exports: [PaymentService],
})
export class PaymentModule {}
// Service that uses the strategies without the need for modification when new ones are added.
@Injectable()
export class PaymentService {
private strategies: Map<string, PaymentStrategy> = new Map();
constructor(
@Inject(PAYMENT_STRATEGIES) paymentStrategies: PaymentStrategy[],
) {
paymentStrategies.forEach((strategy: PaymentStrategy) => {
this.strategies.set(strategy.getName(), strategy);
});
}
async processPayment(
orderId: string,
amount: number,
method: string,
): Promise<PaymentResult> {
const strategy = this.strategies.get(method);
if (!strategy) {
throw new BadRequestException(`Payment method '${method}' not supported`);
}
return strategy.pay(orderId, amount);
}
}
❌ Fragile and hard-to-maintain code:
@Injectable()
export class PaymentService {
async processPayment(orderId: string, amount: number, method: string) {
if (method === 'paypal') {
// PayPal specific logic
return { success: true };
} else if (method === 'stripe') {
// Stripe specific logic
return { success: true };
} else if (method === 'bitcoin') {
// And when we add new payment methods, we modify this class over and over again.
return { success: true };
}
throw new BadRequestException('Payment method not supported');
}
}
How can you spot when you're violating this principle? If you have many if/else
statements or type-based switch
cases, you are probably violating the OCP. Do you have to modify this class every time you add a new variant? Bad sign.
The real benefit is that when it's time to add Apple Pay payments, you simply create a new strategy without modifying existing code—no risk of breaking proven functionality! In a recent e-commerce project, we added three new payment providers in three months and thanks to this approach, we never had to touch our core payment service.
L — Liskov Substitution Principle (LSP)
Subclasses must be substitutable for their base classes without altering the behaviour of the program.
Building on our understanding of focused responsibilities and extensibility, let's explore how to ensure our implementations are truly interchangeable. Think of this principle like car parts—if you replace your headlights, your car should still work exactly the same way.
This principle may seem abstract, but it has very concrete applications. It is about making sure that when you extend a class, the new implementation respects the original contract.
✅ Correct implementation:
What makes this example shine is how the storage implementations can be swapped without changing the code that uses them:
// Base class defining the contract
export interface FileStorage {
saveFile(fileName: string, content: Buffer): Promise<string>;
getFile(filePath: string): Promise<Buffer>;
deleteFile(filePath: string): Promise<void>;
}
// Local storage implementation
@Injectable()
export class LocalFileStorage implements FileStorage {
async saveFile(fileName: string, content: Buffer): Promise<string> {
const path = `./uploads/${fileName}`;
await fs.writeFile(path, content);
return path;
}
async getFile(filePath: string): Promise<Buffer> {
return fs.readFile(filePath);
}
async deleteFile(filePath: string): Promise<void> {
await fs.unlink(filePath);
}
}
// S3 implementation
@Injectable()
export class S3FileStorage implements FileStorage {
constructor(private readonly s3Client: S3Client) {
super();
}
async saveFile(fileName: string, content: Buffer): Promise<string> {
await this.s3Client.send(
new PutObjectCommand({
Bucket: 'my-bucket',
Key: fileName,
Body: content,
}),
);
return `s3://my-bucket/${fileName}`;
}
async getFile(filePath: string): Promise<Buffer> {
const key = filePath.replace('s3://my-bucket/', '');
const response = await this.s3Client.send(
new GetObjectCommand({
Bucket: 'my-bucket',
Key: key,
}),
);
return Buffer.from(await response.Body.transformToByteArray());
}
async deleteFile(filePath: string): Promise<void> {
const key = filePath.replace('s3://my-bucket/', '');
await this.s3Client.send(
new DeleteObjectCommand({
Bucket: 'my-bucket',
Key: key,
}),
);
}
}
// Interchangeable use of any implementation
@Injectable()
export class FileService {
constructor(
@Inject(FILE_STORAGE)
private storage: FileStorage,
) {}
async uploadProfilePicture(userId: string, image: Buffer): Promise<string> {
const fileName = `profile_${userId}_${Date.now()}.jpg`;
return this.storage.saveFile(fileName, image);
}
}
export const FILE_STORAGE = Symbol('FILE_STORAGE');
// Nestjs module
@Module({
providers: [
{
provide: FILE_STORAGE,
// Interchangeable use of any implementation
useClass: S3FileStorage, // LocalFileStorage
},
],
})
export class FileModule {}
❌ Violation of the principle:
class Duck {
swim() {
console.log('Swimming');
}
quack() {
console.log('Quack!');
}
}
// It looks like a duck, but does not behave entirely like a duck.
class ElectronicDuck extends Duck {
constructor(private batteryLevel: number) {
super();
}
swim() {
if (this.batteryLevel === 0) {
throw new Error('No battery!'); // Violates Duck's contract
}
console.log('Swimming robotically');
}
}
How can you spot when you're violating this principle? If a subclass throws unexpected exceptions, has stricter preconditions or weaker postconditions than its base class, you are violating the LSP.
The main advantage of LSP is that you can change how it's implemented (e.g. switching from local storage to S3) without affecting other components that use these classes. I recently worked on a project where we started with local file storage for development and seamlessly switched to cloud storage in production without changing a single line of our business logic.
I — Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they do not use.
Now that we understand how implementations should be interchangeable, let's talk about keeping interfaces focused. This idea suggests that it's better to have a few simple interfaces instead of one big interface that does everything. It's like choosing specialized tools for specific jobs instead of a Swiss army knife for every task.
✅ Specific interfaces:
The strength of this approach is that each repository only implements what it truly needs:
// Separate interfaces by responsibility
interface Readable<T> {
findOne(id: string): Promise<T>;
findAll(filter?: FilterOptions): Promise<T[]>;
}
interface Writable<T> {
create(data: Partial<T>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
}
interface Deletable {
delete(id: string): Promise<void>;
softDelete?(id: string): Promise<void>;
}
// User repository requiring all operations
@Injectable()
export class UserRepository
implements Readable<User>, Writable<User>, Deletable {
// Implementations...
}
// Audit repository requiring only read and write operations
@Injectable()
export class AuditLogRepository
implements Readable<AuditLog>, Writable<AuditLog> {
// Audit logs are never deleted for compliance reasons.
}
// Read-only and update-only configuration repository, not creation or deletion
@Injectable()
class ConfigRepository
implements Readable<Config>, Pick<Writable<Config>, 'update'>
{
async create(): Promise<Config> {
throw new Error('Cannot create new configurations');
}
// Implementations of the other methods
}
❌ Monolithic interfaces:
// An interface that forces unnecessary method implementations
interface Repository<T> {
findOne(id: string): Promise<T>;
findAll(filter?: FilterOptions): Promise<T[]>;
create(data: Partial<T>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
softDelete(id: string): Promise<void>;
restore(id: string): Promise<void>;
count(filter?: FilterOptions): Promise<number>;
// ... and many more methods
}
// Repository forced to implement methods it doesn't need
@Injectable()
export class ReadOnlyRepository<T> implements Repository<T> {
async delete(): Promise<void> {
throw new Error('Operation not allowed');
}
async create(): Promise<T> {
throw new Error('Operation not allowed');
}
// And many more methods that simply throw errors
}
How can you spot when you're violating this principle? If you have classes that use methods that just throw errors or do nothing, you are probably breaking ISP.
The best thing about this is that by making interfaces that are broken down into small parts, each part of the system only depends on the things it really needs. This lets you:
- Create partial implementations without adding extra code that doesn't really matter.
- Make changes that are safer (only affect the people who use that functionality)
- Better understand what each part of the system is responsible for.
When working on a large financial system, this approach allowed different teams to implement only the repository methods they needed for their specific microservices, dramatically reducing code complexity.
D — Dependency Inversion Principle (DIP)
"High-level modules must not depend on low-level modules. Both must depend on abstractions."
"Abstractions should not depend on details. Details must depend on abstractions."
This principle is the cornerstone of clean architecture. NestJS already implements this principle through its dependency injection system, but we can take it further.
Think of this principle like building a house—you don't want the roof design to dictate how the foundation must be built. Instead, both should follow established standards so they can work together while being designed independently.
✅ DIP compliant architecture:
The beauty of this approach is how the high-level OrderService is completely isolated from the logging implementation details:
// Define the abstraction (contract)
export interface Logger {
log(message: string, context?: string): void;
error(message: string, trace?: string, context?: string): void;
warn(message: string, context?: string): void;
debug(message: string, context?: string): void;
}
// Concrete implementation - low-level details
@Injectable()
export class ConsoleLogger implements Logger {
log(message: string, context?: string): void {
console.log(`[${context || 'LOG'}] ${message}`);
}
error(message: string, trace?: string, context?: string): void {
console.error(`[${context || 'ERROR'}] ${message}`);
if (trace) {
console.error(trace);
}
}
warn(message: string, context?: string): void {
console.warn(`[${context || 'WARN'}] ${message}`);
}
debug(message: string, context?: string): void {
console.debug(`[${context || 'DEBUG'}] ${message}`);
}
}
// Alternative implementation - another low-level implementation
@Injectable()
export class CloudLogger implements Logger {
constructor(private readonly cloudLoggingClient: CloudLoggingClient) {}
log(message: string, context?: string): void {
this.cloudLoggingClient.write({
severity: 'INFO',
message,
context,
});
}
// Other implementations
}
// Module configuration using dependency injection
@Module({
providers: [
{
provide: 'Logger',
useClass:
process.env.NODE_ENV === 'production' ? CloudLogger : ConsoleLogger,
},
OrderService,
],
})
export class AppModule {}
// High-level service depending on abstraction, not implementation
@Injectable()
export class OrderService {
constructor(@Inject('Logger') private readonly logger: Logger) {}
async createOrder(orderData: CreateOrderDto): Promise<Order> {
this.logger.log('Creating new order', 'OrderService');
try {
// Logic to create order
return new Order();
} catch (error) {
this.logger.error('Failed to create order', error.stack, 'OrderService');
throw error;
}
}
}
❌ Rigid coupling:
// High-level service with direct dependency on low-level implementation
@Injectable()
export class OrderService {
async createOrder(orderData: CreateOrderDto): Promise<Order> {
console.log('Creating new order'); // Direct dependency on logging mechanism
try {
// Logic to create order
return new Order();
} catch (error) {
console.error('Failed to create order', error); // Coupled to console
throw error;
}
}
}
How can you spot when you're violating this principle? If your business code mentions particular technologies, databases or frameworks, you probably don't follow DIP.
The main advantage is that when you need to change how logging is implemented (e.g. from console to a cloud service), you only change the configuration without affecting any business services. In my experience, this made it possible to completely switch our logging infrastructure during a cloud migration without touching a single line of business code.
Here are some useful tips on how to use SOLID in NestJS.
Now that we've covered all five principles, let's look at how they interconnect in practical NestJS applications:
1. Take advantage of the NestJS module system
NestJS is designed to encourage modularity. Create modules with clear responsibilities, applying the Single Responsibility Principle at the module level:
AuthModule
for authentication and authorization (SRP)UsersModule
for user management (SRP)PaymentModule
for payment processing (SRP)
2. Use providers and injection tokens
This directly implements the Dependency Inversion Principle:
const EMAIL_SERVICE = Symbol('EMAIL_SERVICE');
@Module({
providers: [
{
provide: EMAIL_SERVICE,
useClass:
process.env.EMAIL_SERVICE === 'sendgrid'
? SendgridService
: SmtpService,
},
],
})
export class NotificationModule {}
@Injectable()
export class NotificationService {
constructor(
@Inject(EMAIL_SERVICE)
private readonly emailService: EmailService,
) {}
}
3. Create interfaces for your contracts
TypeScript enables you to define clear contracts, supporting both the Liskov Substitution Principle and Interface Segregation Principle:
export interface CacheService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
delete(key: string): Promise<void>;
}
4. Think about replaceable components
When designing a service, think: "How could I replace this in the future?" This supports the Open/Closed Principle:
- Will you need to change your database?
- What if you want to use another service provider?
- Might you need a different caching strategy?
How do you know if you are applying SOLID correctly?
If you can answer "yes" to these questions, then you’re on the right track:
- For SRP: Can you describe the responsibility of your class in one sentence without using 'and' or 'but'?
- For OCP: Can you add new functionality without modifying existing code?
- For LSP: Can you replace implementations without changing the expected behaviour?
- For ISP: Do your classes depend only on the methods they actually use?
- For DIP: Is your business logic free of references to specific frameworks or technologies?
Conclusion & Challenge
Using SOLID is not just about following academic rules: it is about preparing your code for the future. The code you write today will be maintained tomorrow (or worse, maintained by another developer who may have some questions for you).
In my own experience, these principles have saved countless hours of refactoring and debugging. On a recent project, we were able to switch from one payment processor to another in just two days because we had proper abstractions in place—what could have been weeks of work became a simple implementation of a new strategy.
Challenge for you: Pick a small module from your current NestJS application and try to refactor it according to these principles. Start with identification:
- Classes with many responsibilities (SRP violations)
- Type-based conditionals (OCP violations)
- Large interfaces not fully used by all (ISP violations)
Have you already applied any of these principles in your code and what benefits have you noticed? Let me know in the comments!
Remember: well-designed code is not only readable today, but maintainable tomorrow. Your future self (and your team) will thank you for it.