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.

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():

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

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

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