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/outboxnpm install @nestjs/outboxThe 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():
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:
| Option | Default | Description |
|---|---|---|
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. |
key | none | (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 policy | A channel fails | On a redelivery |
|---|---|---|
failFast | Thrown; the outbox retries the message with its retry schedule | The completed channels are skipped; the failed one and the rest run |
continueOnError (default) | Reported (notification.failed, delivery tracking); the message completes and is not retried | If 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
keyoption 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
failFasta failed write is thrown, and the outbox retry sends the channel again, even though it delivered: thenotification.sentevent (and the delivery-trackingsentrow) for the first send was already emitted, so listeners see it twice. UndercontinueOnErrorthe 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.
Redis
A dedicated notification worker without BullMQ. The dispatcher pushes serialized jobs onto a Redis list; a long-running worker drains it with a blocking BRPOP loop and isolates per-job failures.
Recipes
Task-focused guides for the things you actually reach for — on-demand sends, queueing end-to-end, writing your own channel, and wiring up Telescope.