Notification channels as strategies
When you're building a system that notifies users—whether through email, SMS, WhatsApp, push notifications, or Slack—you face a design challenge: each channel has different APIs, retry logic, and delivery guarantees. Rather than scattering channel-specific code throughout your application, notification channels as strategies gives you one unified interface while letting each channel handle its own complexities. This pattern isn't just cleaner; it's how production systems at scale avoid the chaos of managing five different notification flows side by side.
The problem: channel sprawl
I've seen this pattern emerge countless times across e-commerce, CRM, and ride-hailing platforms. Early on, you send email notifications. Then SMS becomes a requirement. Later, push notifications. If you're not careful, you end up with code like this:
async function notifyUser(userId: string, message: string) {
const user = await getUser(userId);
if (user.email && user.emailNotificationsEnabled) {
await sendEmail(user.email, message);
}
if (user.phoneNumber && user.smsNotificationsEnabled) {
await sendSms(user.phoneNumber, message);
}
if (user.pushToken && user.pushNotificationsEnabled) {
await sendPushNotification(user.pushToken, message);
}
if (user.whatsappNumber && user.whatsappNotificationsEnabled) {
await sendWhatsappMessage(user.whatsappNumber, message);
}
}
This function becomes a god method—it knows about every notification channel, handles every edge case, and breaks when you add channel six. You can't test channels independently, you can't retry one channel without retrying others, and adding a new channel means touching production logic everywhere.
Each channel implements send(message)
The strategy pattern solves this by defining a common interface that every channel implements. In TypeScript, that's straightforward:
interface NotificationChannel {
send(recipient: string, message: string): Promise<void>;
canHandle(user: User): boolean;
}
class EmailChannel implements NotificationChannel {
async send(recipient: string, message: string): Promise<void> {
await emailProvider.send({
to: recipient,
subject: 'Notification',
body: message,
});
}
canHandle(user: User): boolean {
return !!user.email && user.emailNotificationsEnabled;
}
}
class SmsChannel implements NotificationChannel {
async send(recipient: string, message: string): Promise<void> {
await smsProvider.send({
to: recipient,
body: message,
});
}
canHandle(user: User): boolean {
return !!user.phoneNumber && user.smsNotificationsEnabled;
}
}
class PushNotificationChannel implements NotificationChannel {
async send(recipient: string, message: string): Promise<void> {
await pushService.send({
token: recipient,
message: message,
});
}
canHandle(user: User): boolean {
return !!user.pushToken && user.pushNotificationsEnabled;
}
}
Notice each channel knows how to send a message using its provider's API. Email uses a subject line; SMS truncates length; push has token-based delivery. Each channel also declares whether it can handle a specific user—that's where preferences live. No channel needs to know about the others.
User preferences choose channels
In a real platform, users have preferences for which channels they want to receive notifications on. They might want order confirmations via email but promotional messages via push only. A notification dispatcher uses the user's preferences to select which channels to invoke:
class NotificationDispatcher {
private channels: NotificationChannel[];
constructor(channels: NotificationChannel[]) {
this.channels = channels;
}
async notify(
userId: string,
message: string,
notificationType: 'order_confirmation' | 'promotion' | 'alert'
): Promise<void> {
const user = await getUserWithPreferences(userId);
// Determine which channels apply to this notification type
const preferences = user.notificationPreferences[notificationType] || [];
// Filter channels that (1) the user enabled for this type and (2) can handle the user
const applicableChannels = this.channels.filter(
channel => preferences.includes(channel.name) && channel.canHandle(user)
);
// Send on all applicable channels, in parallel
await Promise.all(
applicableChannels.map(channel =>
this.sendWithRetry(channel, user, message)
)
);
}
private async sendWithRetry(
channel: NotificationChannel,
user: User,
message: string,
maxAttempts: number = 3
): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await channel.send(user.getRecipientFor(channel), message);
return;
} catch (error) {
lastError = error as Error;
// Wait before retrying (exponential backoff)
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt - 1) * 1000)
);
}
}
// Log failure after all retries exhausted
logger.error(`Failed to send via ${channel.name}:`, lastError);
}
}
The dispatcher decouples preferences from delivery logic. A user's preferences live in the database and can change without code changes. The dispatcher checks if a channel is applicable before using it, and handles retries at the channel level.
Add channels without touching callers
One of the biggest wins of this pattern is extensibility. When your product manager asks for WhatsApp notifications (and they always do), you implement one new class and register it:
class WhatsappChannel implements NotificationChannel {
async send(recipient: string, message: string): Promise<void> {
await whatsappClient.send({
to: recipient,
text: message,
});
}
canHandle(user: User): boolean {
return !!user.whatsappNumber && user.whatsappNotificationsEnabled;
}
}
// At startup or configuration time:
const dispatcher = new NotificationDispatcher([
new EmailChannel(),
new SmsChannel(),
new PushNotificationChannel(),
new WhatsappChannel(), // Just add it
// No other code changes needed
]);
</code><p></p>
<p>Your business logic that calls <code>dispatcher.notify()</code> doesn't change at all. Tests don't change. Configuration doesn't balloon. This is the open-closed principle in action: open for extension (new channels), closed for modification (existing code).</p>
<h2 id="retry-per-channel-independently">Retry per channel independently</h2>
<p>In a distributed system, failures are common. Email providers go down, SMS gateways rate-limit, push services have certificate issues. You shouldn't fail the entire notification just because one channel failed—especially if another channel succeeded.</p>
<p>The dispatcher I showed earlier handles retries per channel, but in production I'd make this even more robust by persisting failed attempts to a queue:</p>
<pre><code class="language-typescript">
class QueuedNotificationDispatcher extends NotificationDispatcher {
constructor(
channels: NotificationChannel[],
private queue: MessageQueue
) {
super(channels);
}
async notify(
userId: string,
message: string,
notificationType: string
): Promise<void> {
const user = await getUserWithPreferences(userId);
const applicableChannels = this.getApplicableChannels(user, notificationType);
// Send immediately, but queue failed attempts
const results = await Promise.allSettled(
applicableChannels.map(channel =>
channel.send(user.getRecipientFor(channel), message)
)
);
// For failures, queue for retry
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'rejected') {
const channel = applicableChannels[i];
await this.queue.enqueue({
userId,
channelName: channel.name,
message,
notificationType,
failedAt: new Date(),
retryCount: 0,
});
}
}
}
}
Now when an SMS provider is temporarily down, you queue that delivery. A separate worker process pulls from the queue and retries at intervals. Email gets through immediately to some users while SMS eventually delivers to others. Each channel retries independently on its own schedule, without blocking the happy path.
Practical considerations in production
I've implemented this pattern in a ride-hailing platform where we send order notifications, driver updates, and support escalations across email, SMS, push, and WhatsApp. A few things you'll hit in production:
Channel-specific templating: SMS has 160 characters; email can be rich HTML; push has character limits and special formatting. Move templating into the channel itself:
interface NotificationChannel {
send(recipient: string, message: NotificationMessage): Promise<void>;
canHandle(user: User): boolean;
formatMessage(data: any): NotificationMessage;
}
Delivery tracking: For audit and analytics, log each channel's success or failure. Store delivery IDs (like email message IDs or SMS tracking IDs) for troubleshooting.
Rate limiting per channel: Some channels charge per message or have strict rate limits. Add capacity management at the channel level, using tokens or queuing to respect limits without dropping notifications.
Graceful degradation: If email fails but SMS succeeds, that's a win. Your dispatcher should try all channels and report partial success. Don't treat one channel's failure as total failure unless you explicitly want to.
Common mistakes
- Mixing retry logic with dispatch logic: Put retries in the dispatcher or a wrapper, not in individual channels. Channels should be simple; they attempt once and either succeed or throw.
- Storing user preference as channel names in code: Store preference names (like "email", "sms") in the database, not hardcoded enums. A release shouldn't be needed to add a new channel.
- Ignoring partial failures: Don't roll back an entire notification because one channel failed. Log failures and move on. Use a separate process to retry failures asynchronously.
- Not testing channels in isolation: Mock the providers and test each channel independently. A flaky email provider shouldn't make your test suite fragile.
- Assuming all channels need the same payload: An SMS message is structurally different from an email or push notification. Let each channel format its own message.
Key takeaways
- Define one
NotificationChannelinterface with asend()method; each channel (email, SMS, push, WhatsApp) implements it independently. - Store user preferences in the database by notification type and channel, decoupling preferences from code.
- A dispatcher selects applicable channels based on user preferences and channel availability, then sends in parallel.
- Add new channels without modifying existing code—just implement the interface and register it at startup.
- Retry failures per channel independently, ideally using a persistent queue so failures don't block other channels or future retries.
- Log channel-specific failures and delivery IDs for debugging and audit trails.






Comments
Loading comments…