Writing a custom channel
Implement a ChannelDriver and the library discovers it automatically. Build an SMS channel end to end — the driver, a type-safe notification interface, and a global module.
A channel is just a class that implements ChannelDriver: a channel name and a send() method.
Register it as a provider and the ChannelRegistry
discovers it via Nest's DiscoveryService — no registration array, no central list. Let's build an
SMS channel against a fictional SMS API.
Export a channel handle and a notification interface
First, create the channel handle with createChannel('sms'). The handle is callable as a method
decorator (@Sms()) and usable as a type-safe token in via() (return [Sms]) — exactly like the
built-in Mail and Database handles. Export it next to the message type and a notification
interface for compile-time safety.
import { createChannel } from '@dudousxd/nestjs-notifications-core';
/** Channel handle: use as `@Sms()` on a payload method, or as a token in `via()`. */
export const Sms = createChannel('sms');
export interface SmsMessage {
body: string;
}
/** Implement this on any notification routed to the `sms` channel for type safety. */
export interface SmsNotification {
toSms(ctx: ChannelContext): SmsMessage;
}createChannel(name) takes just the channel name — the same string your driver reports as its
channel. That one string is the only thing the handle and the driver need to agree on.
Implement the driver
The driver reads the recipient's phone number from routeNotificationFor('sms'), pulls the payload
from the notification's toSms(), and calls the API. If the notification was routed to sms but
never implemented toSms(), throw MissingChannelMethodError — the same error the built-in
channels raise, so the failure is consistent and clear.
Use getHandler(notification, 'sms', 'toSms') to resolve the payload method: it returns the
@Sms()-decorated method if present, otherwise the toSms convention name — so both styles work,
just like the built-in channels.
import {
type ChannelDriver,
MissingChannelMethodError,
type Notifiable,
type Notification,
getHandler,
} from '@dudousxd/nestjs-notifications-core';
import { Injectable } from '@nestjs/common';
import type { SmsMessage } from './sms-notification';
import { SmsClient } from './sms-client';
@Injectable()
export class SmsChannel implements ChannelDriver {
readonly channel = 'sms';
constructor(private readonly client: SmsClient) {}
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
const to = notifiable.routeNotificationFor(this.channel, notification) as string | undefined;
if (!to) return; // no SMS address for this recipient — nothing to do
const handler = getHandler(notification, this.channel, 'toSms');
if (!handler) {
throw new MissingChannelMethodError(
this.channel,
'toSms',
(notification.constructor as { notificationName?: string }).notificationName ??
notification.constructor.name,
);
}
const message = handler({ notifiable }) as SmsMessage;
await this.client.send({ to, body: message.body });
}
}getHandler returns the bound method (or undefined), and MissingChannelMethodError takes the
channel name, the missing method name, and the notification name — it builds the message for you.
Provide it as a global module
Expose the driver as a provider in a global module. Global registration is what lets the registry discover the channel app-wide, the same pattern the built-in channels use.
import { type DynamicModule, Module } from '@nestjs/common';
import { SmsChannel } from './sms.channel';
import { SmsClient } from './sms-client';
@Module({})
export class SmsChannelModule {
static forRoot(): DynamicModule {
return {
module: SmsChannelModule,
global: true, // discoverable everywhere
providers: [SmsClient, SmsChannel],
exports: [SmsChannel],
};
}
}Then import it alongside NotificationsModule:
@Module({
imports: [
EventEmitterModule.forRoot(),
NotificationsModule.forRoot(),
SmsChannelModule.forRoot(),
],
})
export class AppModule {}Discovery is structural, not nominal. ChannelRegistry scans every provider in the container and
keeps any whose instance has a string channel and a send() method — keyed by channel. If two
drivers claim the same name, the last one discovered wins, so keep channel names unique.
Less boilerplate: BaseChannel
The driver above spells out the delivery preamble every channel repeats — resolve the payload method,
raise MissingChannelMethodError when it's missing, read the localization/tenant off the context.
BaseChannel owns that preamble so your driver only writes the transport-specific tail. Extend it,
declare the channel name, and call buildPayload():
import { BaseChannel, type DeliveryContext, type Notifiable, type Notification } from '@dudousxd/nestjs-notifications-core';
import { Injectable } from '@nestjs/common';
import type { SmsMessage } from './sms-notification';
import { SmsClient } from './sms-client';
@Injectable()
export class SmsChannel extends BaseChannel {
readonly channel = 'sms';
constructor(private readonly client: SmsClient) {
super();
}
async send(notifiable: Notifiable, notification: Notification, context?: DeliveryContext) {
const to = notifiable.routeNotificationFor(this.channel, notification) as string | undefined;
if (!to) return;
// resolves @Sms() or the `toSms` convention; throws MissingChannelMethodError if neither exists
const message = this.buildPayload<SmsMessage>(notification, notifiable, 'toSms', context);
await this.client.send({ to, body: message.body });
}
}buildPayload<T>(notification, notifiable, fallbackMethodName, context) finds the handler (decorated
method first, then the convention name), invokes it with { notifiable, localization, tenant } from
the context, and throws the uniform MissingChannelMethodError when the notification implements
neither — so localization and tenant reach the payload method for free. BaseChannel also exposes a
forTenant(fallback, context, resolver?) helper that picks a per-tenant value when both a tenant and
a resolver are present, for channels with per-tenant transports or credentials.
Less boilerplate: defineChannelModule
The built-in simple channels (slack, discord, teams, telegram, webhook) all wire the same shape of
forRoot(): an options provider, an optional per-tenant resolver, then the channel — registered
globally and exported. defineChannelModule() single-sources that skeleton so your module only shapes
its own options:
import { type DynamicModule, Module } from '@nestjs/common';
import { defineChannelModule } from '@dudousxd/nestjs-notifications-core';
import { SmsChannel } from './sms.channel';
import { SMS_OPTIONS } from './tokens';
export interface SmsChannelOptions {
apiKey: string;
from?: string;
global?: boolean;
}
@Module({})
export class SmsChannelModule {
static forRoot(options: SmsChannelOptions): DynamicModule {
return defineChannelModule({
module: SmsChannelModule,
channel: SmsChannel,
optionsToken: SMS_OPTIONS, // the channel injects its options from here
options,
global: options.global ?? true,
});
}
}ChannelModuleConfig is { module, channel, optionsToken, options, resolver?, global? }. Pass
resolver: { token, value } to register a per-tenant/per-notifiable options resolver (it defaults to
null when the value is absent), exactly as the mail channel's resolveTransport does. Under the
hood it builds { module, global, providers: [optionsProvider, resolverProvider?, channel], exports: [channel] } — the same module the hand-written version produced, minus the repetition.
Reach for defineChannelModule when your channel's wiring fits the options + optional resolver +
channel shape (most simple HTTP/webhook channels do). Channels with bespoke transport or renderer
providers (mail, sms with a client, push) build their DynamicModule by hand — the skeleton would
just get in the way.
Use it in a notification
Because you exported the Sms handle, notifications opt into the channel exactly like the built-ins:
annotate the payload method with @Sms() and the channel is inferred — no via() to maintain.
import { Notification } from '@dudousxd/nestjs-notifications-core';
import { Sms, type SmsMessage } from './sms-notification';
@Notification()
export class OtpCode {
constructor(private readonly code: string) {}
@Sms()
toSms(): SmsMessage {
return { body: `Your verification code is ${this.code}` };
}
}The Sms handle also works as a via() token (via() { return [Sms]; }), and implementing
SmsNotification adds compile-time checks on toSms().
await notifications.send(user, new OtpCode('492013'));That's the whole contract. The recipient supplies the address through
routeNotificationFor('sms'), the notification supplies the payload through toSms(), and the
runner handles events and the error policy for you.
See also
- Channels and dispatchers — how discovery works
- Notifications —
via()and per-channel payloads - Reference: configuration — error classes and the error policy
Injecting services
Pull a provider into a notification with NestJS's own @Inject. The notification stays new-able with plain data, and the library fills the service from the Nest container at delivery time — sync and queued.
Telescope integration
Record every notification delivery in the nestjs-telescope dashboard with one watcher. It listens to the events the core already emits — no monkey-patching, no call-site changes.