nestjs-notifications
Concepts

Notifications

A notification is a plain class. Annotate each payload method with the channel handle — @Mail(), @Database() — and via() is inferred automatically. No magic strings; add the channel interface for compile-time type safety when you want it.

A notification is a plain class that answers two questions: which channels should this go to, and what should each channel send. With the decorator API you answer both at once: annotate a payload method with the channel's handle — @Mail(), @Database() — and the channels are inferred from those decorators. One class, one event, no via() array to keep in sync.

invoice-paid.notification.ts
import { Notification } from '@dudousxd/nestjs-notifications-core';
import { Database } from '@dudousxd/nestjs-notifications-database';
import { Mail, MailMessage } from '@dudousxd/nestjs-notifications-mail';

@Notification()
export class InvoicePaid {
  constructor(
    private readonly invoiceId: string,
    private readonly amount: number,
  ) {}

  @Mail()
  toMail(): MailMessage {
    return new MailMessage()
      .subject(`Invoice ${this.invoiceId} paid`)
      .greeting('Thanks for your payment!')
      .line(`We received your payment of $${this.amount.toFixed(2)}.`);
  }

  @Database()
  toDatabase(): Record<string, unknown> {
    return { invoiceId: this.invoiceId, amount: this.amount };
  }
}

That's it — @Mail() and @Database() declare the channels and the payload for each. No implements, no string list. Add a channel by adding a decorated method; remove one by deleting it.

Channel handles

Each channel package exports a handle — Mail, Database, Broadcast, Slack — and it plays two roles:

  • A method decorator. @Mail() on toMail() marks that method as the mail payload and adds mail to the inferred channel list. There are no magic strings to misspell.
  • A type-safe token. The same handle works as a value in via() (see below), so you never hand-write 'mail' either way.

Annotate one method per channel you want. The method name is up to you — @Mail() build() works just as well as @Mail() toMail() — though the conventional to<Channel>() names read nicely and match the fallback the channels look for.

Inferred via

Because the decorators already say which channels to use, via is computed for you: the notification above sends over mail and database with nothing else to declare. This is the recommended default.

You only reach for an explicit via() when routing must vary per recipient — see Alternative: explicit via() below.

Type safety

The decorator is all you need at runtime — the engine finds @Mail() and calls it, and a missing or misspelled payload method surfaces as a MissingChannelMethodError at send time. For compile-time safety you layer typing on top, from light to strict — pick the tier you want:

1. Annotate the return type — the quickest guard. TypeScript flags a payload method that builds the wrong shape:

@Mail() toMail(ctx: ChannelContext): MailMessage {   // return anything else → type error
  return new MailMessage().subject('…');
}

2. Implement the channel interface — the full contract, and the recommended setup for a real app. Each channel exports a <Channel>Notification interface (MailNotification, SmsNotification, …); implementing it turns a missing, misnamed, or mis-typed method into a build error instead of a runtime one:

import { Notification, type ChannelContext } from '@dudousxd/nestjs-notifications-core';
import { Mail, MailMessage, type MailNotification } from '@dudousxd/nestjs-notifications-mail';
import { Sms, type SmsMessage, type SmsNotification } from '@dudousxd/nestjs-notifications-sms';

@Notification()
export class OrderShipped implements MailNotification, SmsNotification {
  @Mail() toMail(ctx: ChannelContext): MailMessage { /* … */ }
  @Sms()  toSms(ctx: ChannelContext): SmsMessage | string { /* … */ }
}

The decorator and the interface are complementary, not redundant: the decorator does the runtime wiring (which channels, which payload), and the interface is the opt-in compile-time check. Reach for the decorator alone on a quick path; add implements when you want the contract enforced. (Don't treat them as separate styles — implements is the typed way, and the return annotation in tier 1 is just optional sugar on top.)

Injecting services

A notification is new-able with plain data, but sometimes building a payload needs a service — a UrlService to sign links, a ConfigService to read a base URL. Mark a property with NestJS's own @Inject(Token) and the library populates it from the Nest container at delivery time:

invoice-paid.notification.ts
import { Inject } from '@nestjs/common';
import { Notification } from '@dudousxd/nestjs-notifications-core';
import { Mail, MailMessage } from '@dudousxd/nestjs-notifications-mail';
import { UrlService } from '../url.service';

@Notification()
export class InvoicePaid {
  @Inject(UrlService)
  private readonly urls!: UrlService;

  constructor(private readonly invoiceId: string) {}

  @Mail()
  toMail(): MailMessage {
    return new MailMessage()
      .subject(`Invoice ${this.invoiceId} paid`)
      .action('View invoice', this.urls.signed(`/invoices/${this.invoiceId}`));
  }
}

You still construct it with just data — new InvoicePaid(invoiceId) — and the urls property is filled in before the channel runs. Because a notification is new-ed (not created by Nest), Nest never wires it; the library reads Nest's property-injection metadata and fills it in itself. Injection is also re-run after async rehydration, so a queued notification gets its services back on the worker too. See the Injecting services recipe for the full pattern.

Injection is idempotent and only fills properties that are still undefined, so it's safe whether the notification is sent inline or queued, and on the worker after a rebuild.

Stable names for the queue

@Notification({ name }) is an optional class marker. Its main job is pinning a stable name so async (de)serialization survives a class rename — the worker looks the notification up by this name when rebuilding it:

@Notification({ name: 'invoice.paid' })
export class InvoicePaid { /* … */ }

Channel inference works with or without it — the decorator reads the @Mail()/@Database() metadata regardless. Add a name once a notification can be dispatched asynchronously, then you're free to rename the class. Without it, the class name is used.

Per-instance display name: notificationType()

@Notification({ name }) is a class-level name. Implement notificationType() on the instance when one generic notification class carries many different event names in its data — otherwise that class-level name would be a useless constant everywhere the type is displayed or stored (channel logging, lifecycle events, the database channel's persisted type column, Telescope):

@Notification()
export class GenericEvent {
  constructor(private readonly eventName: string, private readonly payload: unknown) {}

  notificationType(): string {
    return this.eventName; // e.g. "invoice.paid", "invoice.overdue"
  }

  @Database()
  toDatabase() {
    return this.payload;
  }
}

Precedence for display/persistence surfaces is notificationType() > class-level @Notification({ name }) > class name.

notificationType() is not used for queue rehydration. The async dispatch registry (NotificationsModule.forRoot({ notifications: [...] })) always keys off the class-level name — it identifies WHICH CLASS to reconstruct on the worker, and an instance-level type is data, not a class identity, so it isn't even available before the instance exists. A generic class with a notificationType() override is still looked up by its class name when dequeued. See Async dispatch.

shouldQueue

Set shouldQueue = true to route the notification through the configured async dispatcher instead of delivering inline:

@Notification({ name: 'invoice.paid' })
export class InvoicePaid {
  shouldQueue = true;
  // …
}

You can also set a queue hint that dispatchers may use to target a specific queue.

Send results

send, notify, sendNow, and sendAsync all resolve to a SendResult[] — one entry per notifiable (in the order you passed them). Each entry has the notifiable and a results array with one ChannelResult per channel:

import type { SendResult } from '@dudousxd/nestjs-notifications-core';

const [result] = await notifications.send(user, new InvoicePaid(invoiceId));

result.results;
// [
//   { channel: 'mail', status: 'sent', response: { messageId: '…' } },
//   { channel: 'database', status: 'sent' },
// ]

A ChannelResult carries:

FieldTypeNotes
channelstringThe channel name.
status'sent' | 'failed' | 'skipped' | 'queued'Outcome of this channel for this notifiable.
responseunknownThe transport's response, when it returns one (sent only).
errorunknownThe thrown error (failed only).

status tells you exactly what happened: sent when the channel delivered, failed when it threw (under continueOnError, see error handling), skipped when shouldSend gated it out, and queued when the notification was routed through the async dispatcher instead of delivered inline.

Conditional channels with shouldSend

shouldSend(notifiable, channel) is a per-channel gate evaluated just before delivery. Return false to skip that channel for that notifiable — it's recorded as skipped in the result and the channel never runs:

@Notification()
export class InvoicePaid {
  shouldSend(notifiable: User, channel: string): boolean {
    // Don't email users who opted out, but still write the in-app row.
    if (channel === 'mail') return notifiable.wantsEmail;
    return true;
  }

  @Mail() toMail() { /* … */ }
  @Database() toDatabase() { /* … */ }
}

This differs from via(): via() decides the channel list up front, while shouldSend runs at delivery time per notifiable and is the cleanest way to honour a per-recipient opt-out.

Ad-hoc channel filtering with only / except

shouldSend and via() live on the notification. When the caller wants to override the channels for one specific send, chain only (allow-list) or except (deny-list) off the service:

billing.service.ts
@Injectable()
export class BillingService {
  constructor(private readonly notifications: NotificationService) {}

  async resendEmail(user: User, invoiceId: string) {
    // Only email, even if the notification's via() also lists database + push.
    await this.notifications.only(['mail']).send(user, new InvoicePaid(invoiceId));
  }

  async welcome(user: User) {
    // Everything the notification declares, minus SMS — for this send only.
    await this.notifications.except(['sms']).send(user, new Welcome());
  }
}

They compose with forTenant and apply to send, notify, sendNow, and sendAsync:

billing.service.ts
@Injectable()
export class BillingService {
  constructor(private readonly notifications: NotificationService) {}

  async paidForTenant(user: User, id: string) {
    await this.notifications
      .forTenant('acme')
      .only(['mail', 'database'])
      .send(user, new InvoicePaid(id));
  }
}

A channel filtered out here never runs and isn't recorded in the SendResult — handy for "resend just the email" actions or muting a noisy channel during an incident, without touching the notification class.

afterSending hook

afterSending(notifiable, channel, response) is a lifecycle hook called after each channel delivers, with the channel's transport response — the same value you get back as response in the SendResult. Use it to record a receipt, log a provider message id, or trigger follow-up work:

@Notification()
export class InvoicePaid {
  @Mail() toMail() { /* … */ }

  async afterSending(notifiable: User, channel: string, response: unknown) {
    if (channel === 'mail') {
      await this.receipts.record(notifiable.id, response);
    }
  }
}

It can be sync or async, and runs once per channel that actually delivered (not for skipped or failed channels).

Delaying delivery

Set delay to push delivery into the future — a number of milliseconds, or an absolute Date:

@Notification({ name: 'invoice.paid' })
export class InvoicePaid {
  delay = 60_000; // one minute from now
  // delay = new Date('2026-06-11T09:00:00Z'); // or an absolute time
}

A delay always routes the notification through the async dispatcher (just like shouldQueue), so the channels in the result come back as queued. BullMQ honours the delay natively; the Redis and event-emitter dispatchers honour it with an in-process timer. The SyncDispatcher has no concept of a delay, so pick a real dispatcher when you rely on it.

serialize — for the queue

When queued, a notification is serialized to { name, data }. By default data is a structural copy of the instance's own properties, which is right for most notifications. Implement serialize() (and optionally a static deserialize()) when you need control:

@Notification({ name: 'invoice.paid' })
export class InvoicePaid {
  constructor(private invoice: Invoice) {}

  @Mail()
  toMail() { /* … */ }

  serialize() {
    return { invoiceId: this.invoice.id }; // store only the id, refetch later
  }
}

Queued notifications are rebuilt on the worker by assigning data onto the prototype — the constructor does not run again. Keep constructor side effects out, or use a custom deserialize(). @Inject properties are re-populated automatically after the rebuild. See Async dispatch.

Alternative: explicit via() and interfaces

The decorator API is the recommended default, but the original interface style still works and is the right tool for conditional, per-recipient routing. Implement via(notifiable) and it overrides decorator inference entirely — branch on the recipient's preferences:

import type { Notifiable, Notification } from '@dudousxd/nestjs-notifications-core';
import { MailMessage, type MailNotification } from '@dudousxd/nestjs-notifications-mail';
import type { DatabaseNotification } from '@dudousxd/nestjs-notifications-database';

export class InvoicePaid implements Notification, MailNotification, DatabaseNotification {
  constructor(private invoice: Invoice) {}

  via(notifiable: User): string[] {
    const channels = ['database'];
    if (notifiable.wantsEmail) channels.push('mail'); // per-recipient choice
    return channels;
  }

  toMail(): MailMessage {
    return new MailMessage()
      .subject('Invoice paid')
      .line(`We received your payment of $${this.invoice.amount}.`);
  }

  toDatabase() {
    return { invoiceId: this.invoice.id, amount: this.invoice.amount };
  }
}

A few things worth knowing:

  • via() can return handles too. Instead of strings, return the channel tokens for type safety: via() { return [Mail, Database]; }. Mix them freely — both forms resolve to the same channel.
  • Implement the channel interface (MailNotification, DatabaseNotification, …) for compile-time safety: forgetting a method, or returning the wrong shape, becomes a type error.
  • You can combine the styles. Decorate your payload methods and implement via() when you want inferred payloads but conditional routing — via() always wins when present.

Return an empty array from via() to send nothing for this recipient. If a notification is routed to a channel but has no method for it, the channel throws a clear MissingChannelMethodError at send time.

On this page