One of the design patterns that caught my attention the most when learning TypeScript was the Strategy Pattern. Although it's not exclusive to TypeScript, this pattern significantly enriches the code by promoting reusability and decoupling.
What does the pattern consist of?
The Strategy Pattern allows a class to modify the algorithm or behavior it uses during execution. This is achieved by defining a series of algorithms, each encapsulated in its own class and following a common interface. This way, behaviors can be easily interchanged without the need to alter the class that implements them.
A real-life example
Imagine an application that sends notifications to users, with variable notification methods, such as email, SMS, or push notifications. Instead of coding a single sending method, your application can adopt different notification strategies according to the needs of the moment. This is achieved by implementing a common interface for notification strategies, ensuring that your application knows how to send them, regardless of the chosen method. This approach allows you to easily change notification methods without affecting the overall functioning of the application. At first it sounds a bit confusing, so let's better go to an example with code.
Code where the Strategy Pattern is not being used
In the context of the previous example and using NestJS, let's consider the NotificationService
. This service is responsible for sending different types of notifications, selecting the appropriate method based on an argument passed to the notify
method. The selection of the notification method is done through conditionals, determining whether to send an email, SMS, or push notification depending on the case:
import { Injectable } from '@nestjs/common';
@Injectable()
export class NotificationService {
async notify(user: User, message: string, method: string): Promise<void> {
if (method === 'email') {
console.log(`Sending email to ${user.email}: ${message}`);
// Logic to send email
} else if (method === 'sms') {
console.log(`Sending SMS to ${user.phone}: ${message}`);
// Logic to send sms
} else if (method === 'push') {
console.log(`Sending push notification: ${message}`);
// Logic ton send push notification
} else {
throw new Error('Invalid notification method');
}
}
}
The following code fragment demonstrates how we would use the NotificationService
to send two notifications, one by email and another by SMS. In this scenario, the notification method is decided at runtime, which shows the flexibility of our service to adapt to different notification requirements:
export class AppService {
constructor(private readonly notificationService: NotificationService) {}
async sendNotification() {
const user = { email: '*Emails are not allowed*', phone: '+1234567890' };
const message = 'This is a test message.';
// Choose the method in execution time
await this.notificationService.notify(user, message, 'email');
await this.notificationService.notify(user, message, 'sms');
}
}
This approach reveals several significant disadvantages that deserve attention:
High Coupling: The selection and execution of notification methods are tightly integrated within a single class or function, complicating code management and expansion. This high degree of coupling reduces flexibility and increases maintenance complexity.
Violation of the Open/Closed Principle (OCP): Incorporating a new notification method involves directly modifying the NotificationService class, adding more conditions. This contravenes the OCP principle, according to which software entities should allow their extension without the need to modify their existing content, thus promoting greater scalability and maintainability.
Testing Complexity: Verifying the correct functioning of NotificationService becomes more arduous. Each condition needs to be tested individually to confirm its operability, increasing the effort and complexity of testing.
Refactoring the code
To apply the Strategy pattern in our refactoring of NotificationService
, we'll remove the conditionals from the class. Instead, we'll modify the class to accept an object through its constructor. This object must adhere to a specific interface that includes a send
method. This method will be responsible for executing the sending logic for the corresponding notification type, thus allowing NotificationService
to delegate the responsibility of sending the notification, decoupling the service from the specific implementation details of each sending method. In this way, NotificationService
acts merely as a context for sending notifications, without being tied to any particular sending method.
We'll start by defining the common interface that all objects intended to be used as notification strategies must fulfill. This interface ensures that any notification strategy provides an implementation of the send
method, which is responsible for executing the specific notification sending logic.
// notification.strategy.interface.ts
export interface NotificationStrategy {
send(user: User, message: string): Promise<void>;
}
After defining the NotificationStrategy
interface, we proceed to integrate it into our NotificationService
. This change involves adjusting the NotificationService
constructor to accept an object that implements NotificationStrategy
. By doing this, we eliminate the need for conditionals within the service, delegating the responsibility of the sending logic to the send
method of the injected strategy object. The service's notify
function is therefore significantly simplified, limiting itself to invoking send on the provided strategy.
// notification.service.ts
import { Injectable } from '@nestjs/common';
import { NotificationStrategy } from './notification.strategy.interface';
@Injectable()
export class NotificationService {
constructor(private strategy: NotificationStrategy) {}
async notify(user: User, message: string): Promise<void> {
await this.strategy.send(user, message);
}
}
Finally, to complete our implementation of the strategy pattern, we create different types of notification strategies that NotificationService
can use. Specifically, we will develop EmailNotificationStrategy
and SmsNotificationStrategy
, each with its respective send
method. These concrete strategies encapsulate the specific logic for sending notifications via email and SMS, respectively.
// email.notification.strategy.ts
import { NotificationStrategy } from './notification.strategy.interface';
import { Injectable } from '@nestjs/common';
@Injectable()
export class EmailNotificationStrategy implements NotificationStrategy {
async send(user: User, message: string): Promise<void> {
console.log(`Sending email to ${user.email}: ${message}`);
// here the logic to send email
}
}
// sms.notification.strategy.ts
import { NotificationStrategy } from './notification.strategy.interface';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SmsNotificationStrategy implements NotificationStrategy {
async send(user: User, message: string): Promise<void> {
console.log(`Sending SMS to ${user.phone}: ${message}`);
// here the logic to send SMS
}
}
To maximize the flexibility of NotificationService
and allow it to change its notification strategy at runtime, we introduce a setter method, setStrategy
. This method allows updating the notification strategy used by NotificationService, facilitating adaptation to different needs without compromising the current instance of the service.
// notification.service.ts
import { Injectable } from '@nestjs/common';
import { NotificationStrategy } from './notification.strategy.interface';
@Injectable()
export class NotificationService {
constructor(private strategy: NotificationStrategy) {}
setStrategy(strategy: NotificationStrategy) {
this.strategy = strategy;
}
async notify(user: User, message: string): Promise<void> {
await this.strategy.send(user, message);
}
}
With the implementation of the strategy pattern in our notification system, we have demonstrated how code flexibility and maintainability can be significantly increased. The following example illustrates how AppService
can dynamically change the notification strategy from EmailNotificationStrategy
to SmsNotificationStrategy
, adapting to different notification needs at runtime:
// app.service.ts
import { Injectable } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { EmailNotificationStrategy } from './email.notification.strategy';
import { SmsNotificationStrategy } from './sms.notification.strategy';
@Injectable()
export class AppService {
constructor(
private readonly notificationService: NotificationService,
private readonly emailStrategy: EmailNotificationStrategy,
private readonly smsStrategy: SmsNotificationStrategy,
) {}
async sendNotification() {
const user = { email: '*Emails are not allowed*', phone: '+1234567890' };
const message = 'This is a test message.';
// Cambiar estrategia según el contexto
this.notificationService.setStrategy(this.emailStrategy);
await this.notificationService.notify(user, message);
// Cambiar a SMS
this.notificationService.setStrategy(this.smsStrategy);
await this.notificationService.notify(user, message);
}
}
This approach not only validates the versatility of the strategy pattern but also underlines its value in creating modular and extensible applications. By separating notification logic into concrete and interchangeable strategies, we facilitate the addition of new notification methods and improve the system's testability. Furthermore, we adhere to the open/closed principle, allowing our system to evolve with minimal changes to existing code.
In summary, the application of the strategy pattern in notification management in TypeScript with NestJS demonstrates how software design principles can be put into practice to build robust, flexible, and maintainable systems. The ability to change strategies at runtime not only enriches our application with operational flexibility but also highlights the importance of forward-thinking software design.