Aviary
Recipes

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.

sms-notification.ts
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.

sms.channel.ts
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.

sms-channel.module.ts
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:

app.module.ts
@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.

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.

otp-code.notification.ts
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

On this page