In modern applications, notifications like password resets and system alerts are vital to keep users informed and engaged But this often leads to notification logic scattered around the codebase tightly coupled to business logic and delivery channels. Over time, this can create systems that are repetitive, and difficult to scale and maintain. Instead of adding more conditionals or special cases, a better approach is to treat notifications as a first-class architectural concern. This is one of the strengths of the strategy pattern.
Why use the strategy pattern for notifications? For me it is because it allows you to define a collection of algorithms, encapsulate each one in its own class, and make them reusable and interchangeable. This will help to reduce tight coupling, thereby reducing the need for mocking and better testing.
The diagram above shows a modular and extensible notification system based on the strategy pattern. The architecture is divided into three main parts: The Core Notification System, Concrete Implementation, and the Application Domain.
Core Notification System: This is the heart of the architecture. It defines shared services and behaviour. In the NotificationStrategy interface, every notification strategy must implement a send() method that takes some data and configuration. To avoid repeating common functionality, the architecture uses a BaseNotificationStrategy abstract class. It provides methods like prepareContent() and references shared dependencies i.e TemplateService and TransportService. A NotificationConfig object is passed to each strategy. It contains information like which template to use, who the recipients are, etc. This makes it easy to change behaviour without touching strategy internals.
Concrete Implementations: These are actual working strategies based on specific use cases or channels. EmailNotificationStrategy uses the email transport and template system to send a message. SmsNotificationStrategy does the same but formats contents and routes it through SMS. OrderNotificationStrategy is an example domain specific strategy. It uses the base class also includes logic that may be tied to an order. Each of the the strategy implements NotificationStrategy so they can be invoked in the same way.
Application Domain: This is where real world data lives in the architecture. For example, User contains contact fields like email and phone used by strategies to determine how to reach a recipient while Order is a sample domain entity that might trigger notifications, maybe after an order is placed or delivered. Domain models stay clean and don’t contain any notification logic themselves. They are used to trigger the strategies.
Decoupling for Maintainability - Strategy pattern isolates notifications
const orderNotifier = new OrderNotificationStrategy(
templateService,
transportService
);
async function processOrder(order: Order) {
// ... order processing logic
await orderNotifier.send(order, {
templateKey: "order-confirmation",
recipients: [user.email]
});
}
Channel Agnostic - Strategy abstracts channel specifics
const smsNotifier = new SmsNotificationStrategy(
templateService,
smsTransport
);
// Or multi-channel notification
class OrderNotificationStrategy extends BaseNotificationStrategy<Order> {
async send(data: Order, config: NotificationConfig) {
// Send email
await super.send(data, config);
// Also send SMS
const smsConfig = { ...config, templateKey: "order-sms" };
await smsStrategy.send(data, smsConfig);
}
}
Template Centralisation - Centralised template management
templateService.registerTemplate("order-confirmation", `
Hello {CUSTOMER_NAME},
Your order {ORDER_ID} containing:
{ORDER_ITEMS}
Total: {ORDER_TOTAL}
`);
// Strategy uses template service
class OrderNotificationStrategy {
async send(data: Order) {
const content = this.templateService.render(
"order-confirmation",
new Map([
["CUSTOMER_NAME", data.user.name],
["ORDER_ID", data.id],
// ...
])
);
}
}
Testability - Strategy pattern enables clean testing
// Mock transport service
const mockTransport = {
sendEmail: jest.fn()
};
test("order notification sends email", async () => {
const strategy = new OrderNotificationStrategy(
templateService,
mockTransport
);
await strategy.send(testOrder, testConfig);
expect(mockTransport.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
subject: "Order Confirmation",
to: ["customer@example.com"]
})
);
});
Here’s an approach I’ve found useful when designing notification systems in typescript:
Start by modelling domain entities. Begin with the basics: Who is receiving the notification and what is it about? Identify the different types of notifications. Think in terms of events. An example could be a user sign-up, deadline reminder, file submission, etc. Each type will need its own strategy. **Create strategy classes for each notification. **Each strategy should define what data to pull, how to format the message, and who it should go to.
Centralise your template logic. Instead of having templates all around the codebase, I find creating a template service or options to be useful. It makes it easier to maintain consistency and support localisation.
Implementing notifications with the Strategy Pattern transforms them from a fragile afterthought to a core strength of your application. By adopting this architecture in TypeScript, you gain:
This approach may take a little more work upfront, but it pays off in the long run. This allows notifications to become plug and play features capable of adapting to your application context without needing to refactor your codebase extensively.