Notification
Multi-channel notification dispatch with pool-based channel registration. Each notification declares which channels it should be sent to, and the NotificationService routes to matching channels.
Contract (@modularityjs/notification)
class NotificationService {
send(
notification: Notification,
recipients: NotificationRecipient | NotificationRecipient[],
): Promise<NotificationResult[]>;
}
abstract class NotificationChannel {
abstract readonly name: string;
abstract send(envelope: NotificationEnvelope): Promise<void>;
}NotificationService is concrete — it dispatches to channels registered in NotificationChannelsPool. Each notification declares its target channels, and the service filters the pool to matching channels and delivers with bounded concurrency (NotificationConfig.concurrency). A channel that throws yields { success: false, error } in the result list — partial failures don't abort the rest.
Configuration
NotificationModule.forRoot() tunes the delivery fan-out:
NotificationModule.forRoot({ concurrency: 25 });| Field | Type | Default | Description |
|---|---|---|---|
concurrency | number | 10 | Maximum number of channel × recipient deliveries dispatched in parallel per send() call. Must be a positive integer, validated at boot. |
Setup
import { MailModule } from '@modularityjs/mail';
import { NotificationModule } from '@modularityjs/notification';
import { SmsModule } from '@modularityjs/sms';
import { MailNodemailerModule } from '@modularityjs/mail-nodemailer';
import { SmsTwilioModule } from '@modularityjs/sms-twilio';
import { NotificationMailModule } from '@modularityjs/notification-mail';
import { NotificationSmsModule } from '@modularityjs/notification-sms';
const modules = [
MailModule.forRoot({ from: 'noreply@example.com' }),
MailNodemailerModule.forRoot({ host: 'smtp.example.com', ... }),
SmsModule.forRoot({ from: '+15551234567' }),
SmsTwilioModule.forRoot({ accountSid: '...', authToken: '...' }),
NotificationModule,
NotificationMailModule, // registers 'mail' channel
NotificationSmsModule, // registers 'sms' channel
];Channels
Channels are registered via NotificationChannelsPool. Each channel package contributes a NotificationChannel implementation.
Mail (@modularityjs/notification-mail)
Sends notifications as email via MailService. Skips recipients without an email field.
import { NotificationMailModule } from '@modularityjs/notification-mail';| Channel name | Envelope → Mail mapping |
|---|---|
mail | to = recipient email, subject = envelope.subject ?? '', html = envelope.body |
The channel reads the plain-text alternative from envelope.data['text'] when present.
SMS (@modularityjs/notification-sms)
Sends notifications as SMS via SmsService. Skips recipients without a phone field.
import { NotificationSmsModule } from '@modularityjs/notification-sms';| Channel name | Envelope → SMS mapping |
|---|---|
sms | to = recipient phone, body from envelope |
Defining Notifications
A notification is any object implementing the Notification interface:
import type {
Notification,
NotificationEnvelope,
NotificationRecipient,
} from '@modularityjs/notification';
class OrderShipped implements Notification {
readonly channels = ['mail'];
constructor(
private readonly orderNumber: string,
private readonly trackingUrl: string,
) {}
toEnvelope(
recipient: NotificationRecipient,
channel: string,
): NotificationEnvelope {
return {
channel,
recipient,
subject: `Order ${this.orderNumber} shipped`,
body: `<h1>Your order has shipped!</h1><p>Track at: ${this.trackingUrl}</p>`,
};
}
}Per-channel content
The toEnvelope method receives the channel name, allowing different content per channel:
class SystemAlert implements Notification {
readonly channels = ['mail', 'slack'];
constructor(private readonly message: string) {}
toEnvelope(
recipient: NotificationRecipient,
channel: string,
): NotificationEnvelope {
if (channel === 'slack') {
return {
channel,
recipient,
subject: 'Alert',
body: `:warning: ${this.message}`,
};
}
return {
channel,
recipient,
subject: 'System Alert',
body: `<h1>Alert</h1><p>${this.message}</p>`,
};
}
}Sending Notifications
@Injectable()
class OrderService {
constructor(
@Inject(NotificationService)
private readonly notifications: NotificationService,
) {}
async shipOrder(order: Order, customer: Customer): Promise<void> {
// ... ship the order ...
await this.notifications.send(
new OrderShipped(order.number, order.trackingUrl),
{ email: customer.email, name: customer.name },
);
}
}Multiple recipients
await notifications.send(notification, [
{ email: 'alice@example.com', name: 'Alice' },
{ email: 'bob@example.com', name: 'Bob' },
]);Results
send() returns a NotificationResult[] with per-channel success/failure:
const results = await notifications.send(notification, recipient);
for (const result of results) {
if (!result.success) {
console.error(`Channel ${result.channel} failed: ${result.error}`);
}
}Custom Channels
Register custom channels by extending NotificationChannel and contributing to the pool:
import { Inject, Injectable } from '@modularityjs/di';
import { Module } from '@modularityjs/modularity';
import {
NotificationChannel,
NotificationChannelsPool,
NotificationModule,
} from '@modularityjs/notification';
import type { NotificationEnvelope } from '@modularityjs/notification';
@Injectable()
class SlackNotificationChannel extends NotificationChannel {
readonly name = 'slack';
async send(envelope: NotificationEnvelope): Promise<void> {
const webhookUrl = envelope.recipient.metadata?.[
'slackWebhookUrl'
] as string;
if (!webhookUrl) return;
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: envelope.body }),
});
}
}
@Module({
name: 'my-slack-notification',
imports: [NotificationModule],
providers: [SlackNotificationChannel],
pools: [
{
pool: NotificationChannelsPool,
key: 'slack',
useClass: SlackNotificationChannel,
},
],
})
class SlackNotificationModule {}NotificationRecipient
The NotificationRecipient interface provides fields for common channels:
| Field | Type | Used by |
|---|---|---|
id | string | App-specific routing |
email | string | Mail channel |
phone | string | SMS channels |
name | string | Display name |
metadata | Record<string, unknown> | Custom channel data |
Channels check for the fields they need and skip recipients that lack them.
Template Integration
Render templates before creating the notification:
const html = await templateEngine.render('order-shipped', {
orderNumber: order.number,
trackingUrl: order.trackingUrl,
});
await notifications.send(
{
channels: ['mail'],
toEnvelope: (r, c) => ({
channel: c,
recipient: r,
subject: 'Shipped',
body: html,
}),
},
{ email: customer.email },
);Or encapsulate template rendering in the notification class constructor.
Async Delivery
NotificationService.send resolves only after every dispatched channel returns. For fire-and-forget delivery, make individual channels async by pushing onto a queue:
- Mail: Add
@modularityjs/mail-queueto route email through the queue - SMS: Add
@modularityjs/sms-queueto route SMS through the queue - Custom channels: Use
QueueService.publish()inside the channel implementation
This keeps the routing layer simple while letting each channel control its own delivery strategy.