Skip to content

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)

typescript
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:

typescript
NotificationModule.forRoot({ concurrency: 25 });
FieldTypeDefaultDescription
concurrencynumber10Maximum number of channel × recipient deliveries dispatched in parallel per send() call. Must be a positive integer, validated at boot.

Setup

typescript
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.

typescript
import { NotificationMailModule } from '@modularityjs/notification-mail';
Channel nameEnvelope → Mail mapping
mailto = 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.

typescript
import { NotificationSmsModule } from '@modularityjs/notification-sms';
Channel nameEnvelope → SMS mapping
smsto = recipient phone, body from envelope

Defining Notifications

A notification is any object implementing the Notification interface:

typescript
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:

typescript
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

typescript
@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

typescript
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:

typescript
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:

typescript
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:

FieldTypeUsed by
idstringApp-specific routing
emailstringMail channel
phonestringSMS channels
namestringDisplay name
metadataRecord<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:

typescript
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:

This keeps the routing layer simple while letting each channel control its own delivery strategy.