nestjs-notifications
Dispatchers

Outbox

Transactional sends. Write the notification through your database transaction with @nestjs/outbox, so it's delivered only if the transaction commits.

A notification sent next to a database write has two failure modes: the write rolls back but the email already went out, or the write commits but the process dies before sending. The outbox dispatcher removes both: the notification job is written to @nestjs/outbox through your transaction, and delivered after the commit. Relaying, retries, ordering, dead letters and the inbox are the outbox's; this page covers what Aviary adds.

Install

pnpm add @nestjs/outbox
npm install @nestjs/outbox

The dispatcher lives in core's /outbox subpath; @nestjs/outbox is an optional peer of core.

Wire it up

Configure OutboxModule and its store as the NestJS docs describe, then spread outboxDispatcher() into NotificationsModule.forRoot():

app.module.ts
import { OutboxModule } from '@nestjs/outbox';
import { NotificationsModule } from '@dudousxd/nestjs-notifications-core';
import { outboxDispatcher } from '@dudousxd/nestjs-notifications-core/outbox';

@Module({
  imports: [
    OutboxModule.forRoot(),
    NotificationsModule.forRoot({
      ...outboxDispatcher({ transaction: (work) => db.transaction(work) }),
      notifications: [OrderPlaced],
      resolveNotifiable: (ref) => users.findById(ref.id),
    }),
  ],
})
export class AppModule {}

Jobs are serialized like any async dispatcher's (notifications + resolveNotifiable rebuild them) and written to the topic aviary.notifications. OutboxNotificationHandler, registered by the helper, is the @OnOutboxMessage() consumer (aviary.notifications) that rehydrates each job and runs its channels.

Send in a transaction

await db.transaction(async (tx) => {
  await tx.insert(orders).values(order);
  await notifications.inTransaction(tx).send(customer, new OrderPlaced(order));
});

inTransaction(tx) composes with the other scopes (forTenant, only, except). send, notify and sendAsync write the job through tx; the handle stays in memory and is never serialized. sendNow delivers inline and ignores tx. With a dispatcher that isn't transactional, inTransaction() throws instead of delivering outside the transaction.

outboxDispatcher() options:

OptionDefaultDescription
transaction—Opens a transaction for jobs sent without one (sendAsync(), a shouldQueue notification, a quiet-hours re-queue), e.g. (work) => db.transaction(work). Without it, such a job fails to dispatch.
keynone(job) => string | null: the outbox ordering key. Jobs sharing a key are delivered one at a time, in order.

Delivery and retries

Each delivery gets the outbox message id as its idempotencyKey: the mail channel passes it to @nestjs/mail (a redelivered mail keeps its Message-ID) and the webhook channel uses it as webhook-id. For a subscription delivery the webhook channel records that webhook-id in the outbox inbox in the same transaction as Webhooks.dispatch(), so a retried delivery returns { id, duplicate: true } instead of dispatching the event again.

Channels that completed are not run again

A job runs several channels, and the outbox redelivers the whole message: after a failFast failure, after a crash before the message was marked published, or when the relay loses its lease. So the handler records each channel that completes, and a redelivery skips it (its result is suppressed). Only the channels that haven't completed run again: with failFast, the one that failed and those after it.

A record is written for every channel that doesn't fail (sent, skipped, deferred), once it has, in the outbox's inbox store: consumer aviary.notifications:<channel>, message id <outbox message id>:<notifiable type>#<notifiable id> (a SHA-256 digest when either is longer than 255 characters). Each consumer of a message is checked and recorded on its own, in the order the channels run.

Error policyA channel failsOn a redelivery
failFastThrown; the outbox retries the message with its retry scheduleThe completed channels are skipped; the failed one and the rest run
continueOnError (default)Reported (notification.failed, delivery tracking); the message completes and is not retriedIf one happens anyway (crash, lost lease), the completed channels are skipped and the failed ones run again

The records are only as durable as the inbox store. With PostgresOutboxStore / MySqlOutboxStore (or your own store registered as inbox) they survive restarts and are shared by every instance. With the in-memory default they live in the process: a redelivery after a restart runs every channel again. A channel can still run twice if the process dies between its delivery and the record (at-least-once), which is why mail and webhook also pass the idempotencyKey on.

Delivery is at-least-once

The records narrow redeliveries; they don't make a channel exactly-once. A channel runs again (and emits notification.sent again) when:

  • two deliveries of the same message run concurrently: a relay worker loses its lease while still delivering and another picks the message up, or two relays claim it. Each checks the record before sending and writes it after, so both can find it missing and both send. Nothing serializes them per message; the outbox's key option only orders messages that share a key.
  • the record can't be written after a successful send (the process dies, or the inbox store throws). Under failFast a failed write is thrown, and the outbox retry sends the channel again, even though it delivered: the notification.sent event (and the delivery-tracking sent row) for the first send was already emitted, so listeners see it twice. Under continueOnError the write failure is logged and the next redelivery, if any, sends it again.

So treat every channel as at-least-once and make the receiving side idempotent: mail passes the idempotencyKey to @nestjs/mail (same Message-ID, which providers and clients dedupe), the webhook channel sends it as webhook-id (receivers verifying Standard Webhooks dedupe on it, and a subscription delivery is recorded in the inbox inside the dispatch transaction), and notification.sent listeners should tolerate a repeat for the same message id.

The records are inbox rows like the outbox's own, kept until you prune them: the OutboxInbox.prune(olderThan) the NestJS docs recommend removes them too.

Under the default errorPolicy: 'continueOnError' a failed channel is reported (events, delivery tracking) and the outbox message completes. With errorPolicy: 'failFast' the first failure is thrown and the outbox retries the job, skipping the channels that already completed; give the mail channel retry: false so retries don't multiply.

On this page