
Writing Clean, Decoupled Code with the Strategy Pattern
One of the biggest signs of a mature backend developer is understanding how to decouple high-level business logic from low-level implementation details. That’s exactly what the Dependency Inversion Principle (DIP) teaches us, the “D” in SOLID.
High-level modules should not depend on low-level modules. Both should depend on abstractions.
In NestJS, this means your core services shouldn’t know how a specific payment, authentication, or caching mechanism works; they should only depend on interfaces (abstractions) that define what needs to be done.
This is where the Strategy Pattern shines.
By defining a common interface for your algorithms (e.g., PaymentStrategy), and then injecting concrete implementations (StripeStrategy, PaypalStrategy, etc.) at runtime, you keep your code:
- Clean: no messy
if/elseblocks deciding which behavior to use. - Extensible: adding a new strategy doesn’t require touching existing code.
- Testable: you can easily mock or replace strategies in isolation.
- Aligned with SOLID: especially the Open/Closed and Dependency Inversion principles.
In short: The Strategy Pattern helps you invert dependencies so that your business logic depends on abstractions, not implementations, making your NestJS app cleaner, more modular, and easier to evolve.
When To Use It
- When you have multiple algorithms for the same task (e.g., different payment providers).
- When you need to switch behavior dynamically at runtime (e.g., user type, environment, configuration).
- When you want to eliminate large conditional blocks.
Typical use cases in backend systems include:
- Authentication strategies (JWT, API Key, OAuth)
- Payment processing (Stripe, PayPal, internal credit)
- Notification sending (Email, SMS, Push)
- Caching or storage (Redis, Memcached, In-memory)
Implementing the Strategy Pattern in NestJS
Let’s walk through an example where we have multiple payment providers, Stripe and PayPal.
1. Define a Common Interface
We’ll start by defining what all payment strategies must implement:
// payment.strategy.ts
export interface PaymentStrategy {
pay(amount: number): Promise<void>;
}2. Implement Concrete Strategies
Each provider will have its own implementation.
// stripe.strategy.ts
import { Injectable } from '@nestjs/common';
import { PaymentStrategy } from './payment.strategy';
@Injectable()
export class StripeStrategy implements PaymentStrategy {
async pay(amount: number): Promise<void> {
console.log(`Processing $${amount} payment via Stripe`);
// call Stripe API here...
}
}// paypal.strategy.ts
import { Injectable } from '@nestjs/common';
import { PaymentStrategy } from './payment.strategy';
@Injectable()
export class PaypalStrategy implements PaymentStrategy {
async pay(amount: number): Promise<void> {
console.log(`Processing $${amount} payment via PayPal`);
// call PayPal API here...
}
}
3. Create a Strategy Factory (Context)
The factory decides which strategy to use at runtime.
// payment-factory.service.ts
import { Injectable } from '@nestjs/common';
import { StripeStrategy } from './stripe.strategy';
import { PaypalStrategy } from './paypal.strategy';
import { PaymentStrategy } from './payment.strategy';
@Injectable()
export class PaymentFactoryService {
constructor(
private readonly stripeStrategy: StripeStrategy,
private readonly paypalStrategy: PaypalStrategy,
) {}
getStrategy(provider: string): PaymentStrategy {
switch (provider) {
case 'stripe':
return this.stripeStrategy;
case 'paypal':
return this.paypalStrategy;
default:
throw new Error(`Unsupported payment provider: ${provider}`);
}
}
}4. Use It in a Service
Now inject the factory where you need to make payments.
// payment.service.ts
import { Injectable } from '@nestjs/common';
import { PaymentFactoryService } from './payment-factory.service';
@Injectable()
export class PaymentService {
constructor(private readonly paymentFactory: PaymentFactoryService) {}
async processPayment(provider: string, amount: number) {
const strategy = this.paymentFactory.getStrategy(provider);
await strategy.pay(amount);
}
}5. Expose It via a Controller
// payment.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { PaymentService } from './payment.service';
@Controller('payments')
export class PaymentController {
constructor(private readonly paymentService: PaymentService) {}
@Post()
async pay(@Body() body: { provider: string; amount: number }) {
await this.paymentService.processPayment(body.provider, body.amount);
return { message: 'Payment processed successfully!' };
}
}6. Register in a Module
// payment.module.ts
import { Module } from '@nestjs/common';
import { PaymentService } from './payment.service';
import { PaymentController } from './payment.controller';
import { PaymentFactoryService } from './payment-factory.service';
import { StripeStrategy } from './stripe.strategy';
import { PaypalStrategy } from './paypal.strategy';
@Module({
providers: [
PaymentService,
PaymentFactoryService,
StripeStrategy,
PaypalStrategy,
],
controllers: [PaymentController],
})
export class PaymentModule {}
Why This Pattern Shines in NestJS
NestJS’s dependency injection system makes the Strategy Pattern beautifully clean.
You can easily:
- Register new strategies without touching existing ones.
- Replace strategies at runtime via configuration or environment variables.
- Mock them in unit tests.
For example, adding ApplePayStrategy is as simple as:
- Creating a new class implementing
PaymentStrategy. - Registering it in the module.
- Updating the factory’s switch statement.
No refactoring, no “if jungle.” This ensures we follow another SOLID principle, the “O” Open-closed principle
software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification“; that is, such an entity can allow its behaviour to be extended without modifying its source code.
Testing a Strategy
You can test individual strategies without involving the whole NestJS context:
describe('StripeStrategy', () => {
it('should process a payment', async () => {
const strategy = new StripeStrategy();
await strategy.pay(100);
});
});
This isolation is one of the biggest advantages — strategies are self-contained units of logic.
Final Thoughts
The Strategy Pattern brings clean separation of concerns and runtime flexibility to your NestJS applications. When your business logic starts to diverge by condition, that’s usually a sign to extract a strategy.
Start small:
- Identify conditional logic blocks.
- Define a shared interface.
- Encapsulate behaviors into strategies.
- Use a factory or provider to switch dynamically.
Your future self (and your teammates) will thank you.