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.
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()ontoMail()marks that method as the mail payload and addsmailto 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:
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:
| Field | Type | Notes |
|---|---|---|
channel | string | The channel name. |
status | 'sent' | 'failed' | 'skipped' | 'queued' | Outcome of this channel for this notifiable. |
response | unknown | The transport's response, when it returns one (sent only). |
error | unknown | The 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:
@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:
@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.
Notifiables
A notifiable is anything that can receive a notification. Declare per-channel addresses with @RouteFor decorators and mark the id with @NotifiableId — no routeNotificationFor switch, no manual toNotifiableRef.
Channels & Dispatchers
The two pluggable abstractions at the heart of the library. Channels decide how a notification leaves; dispatchers decide where and when it is processed. They are independent.