Outbox & Durable Workflows

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. Both packages are pre-1.0: @nestjs/outbox was at 0.1.0 and @nestjs/workflows at 0.0.1 that day, @nestjs/cqrs at 12.1.0. Their APIs can still change between releases, so pin exact versions and re-read the official pages before upgrading. The examples use a project generated by the Nest 12 CLI (ESM layout, Vitest) and the published packages, on PostgreSQL provided by PGlite (PostgreSQL compiled to WebAssembly, running inside the test process) through Drizzle, without a PostgreSQL server, Redis server or message broker; the analytics service in the outbox example is a real Nest TCP microservice on 127.0.0.1. See What the examples are tested against for what that does and does not prove.

Two problems keep returning once an application stops being a single process that talks to a single database:

  • Dual writes. Saving a row and announcing it to another system are two writes to two systems, and no transaction spans both. The transactional outbox removes the problem by turning the announcement into a row written in the same transaction as the state change.

  • Processes that outlive a process. Charge the card, reserve the stock, wait three days for the carrier, then ask for a review. A request handler cannot hold that, a delayed job cannot continue "after the wait" with the values from before it, and an in-memory CQRS saga forgets everything on a restart. A durable workflow journals each step in the database and replays from the journal.

The patterns themselves are explained in the architecture section: the transactional outbox, sagas, why not distributed transactions and domain events. This page shows how the first-party NestJS packages implement them, and how they relate to what the section already covers: the in-process tools in events, scheduling and queues, the CQRS buses in CQRS, and the guards against duplicate effects in resilience, idempotency and locks.

Choosing the tool

Need Not enough Use

Announce a state change to another module in the same process

Nothing is lost that matters: a lost in-memory event is acceptable

EventEmitter2 or the CQRS EventBus (see CQRS)

Save a row and reliably tell another process or service

ClientProxy.emit(), EventEmitter2 and the EventBus all publish from memory: a crash or a broker outage between the commit and the publish loses the event

@nestjs/outbox

One unit of background work (send an email, transcode a video), with concurrency and rate limits

A workflow is more than you need

BullMQ (see events, scheduling and queues)

A multi-step process that spans days, waits for external events, must charge exactly once and must undo its work if it fails

A queue job cannot continue after a wait with the earlier results, and an in-process saga forgets in-flight state on a restart

@nestjs/workflows

The transactional outbox: @nestjs/outbox

The flow

Outbox.add() inserts one or more messages into an outbox table in the same database transaction as your own write, so both commit or neither does. Nothing is published inside the transaction. After the commit, a relay running in the application claims the committed messages and hands each one to exactly one transport: either the built-in local transport, which runs @OnOutboxMessage() handlers in the same process, or a transport you configure, such as ClientProxyTransport, which calls client.emit() on a microservice client. A failed publish is rescheduled with backoff; a message that never succeeds moves to a dead-letter table. Because retries and crashes can deliver a message twice, every consumer keeps an inbox of the message ids it has processed.

sequenceDiagram autonumber participant API as OrdersService participant DB as Database (orders + nest_outbox) participant R as Relay (polling worker) participant L as local transport (handlers + inbox) participant T as ClientProxyTransport participant S as Analytics microservice API->>DB: BEGIN, INSERT order API->>DB: outbox.add(tx, messages) API->>DB: COMMIT (order and messages together) API-->>R: outbox.notify() (optional wake-up) R->>DB: claim batch (lease + token) R->>L: order.placed L->>DB: inbox: already processed? L-->>R: handler done, id recorded R->>T: analytics.order.placed T->>S: client.emit(topic, envelope) S-->>T: accepted R->>DB: mark published (fenced by lease token) Note over R,DB: failure: reschedule with backoff, after retry.attempts move to dead_letters

The figure is the happy path and the failure rule in the note. If the process dies after the commit, nothing is lost, because the messages are rows; another relay publishes them once the lease expires.

Install and register

npm i --save @nestjs/outbox @nestjs/microservices

The package depends on @nestjs/store-kit for its database executors. It exposes four entry points: @nestjs/outbox (module, services, decorators, errors), @nestjs/outbox/postgres and @nestjs/outbox/mysql (the stores and their executors) and @nestjs/outbox/testing (store contract suites).

The messages must commit with your rows, so the outbox keeps them in your database. You register a store as an ordinary provider, once, in the root module, next to OutboxModule. The store creates and migrates its own schema, nest_outbox on PostgreSQL (tables messages, dead_letters, inbox and migrations), or tables with the prefix nest_outbox_ on MySQL, which has no schemas. Your migrations only create your own tables. Until a store is registered, the outbox keeps everything in memory: fine for a first run, but a restart loses unpublished messages, two instances do not share them and nothing joins your transactions. With NODE_ENV=production and no store, startup fails unless allowInMemoryStorage: true is set.

Here is the database layer of the example. PGlite stands in for a PostgreSQL server; with a server you would create the Drizzle database from a pg pool, usually through @nestjs/drizzle, and the rest does not change.

database.ts
import { PGlite } from '@electric-sql/pglite';
import { sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/pglite';
import { jsonb, pgTable, text } from 'drizzle-orm/pg-core';

export const DATABASE = Symbol('DATABASE');

export const orders = pgTable('orders', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull(),
  total: jsonb('total').$type<number>().notNull(),
  status: text('status').notNull(),
});

export const schema = { orders };

export function createDatabase() {
  // PGlite is PostgreSQL compiled to WebAssembly, running inside the Node.js process.
  const client = new PGlite();
  return { client, db: drizzle(client, { schema }) };
}

export type Database = ReturnType<typeof createDatabase>['db'];
export type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];

/** Your own migration: only the application's table. The outbox store creates its own schema. */
export async function migrateOrders(db: Database) {
  await db.execute(sql`create table if not exists orders (
    id text primary key,
    user_id text not null,
    total jsonb not null,
    status text not null
  )`);
}

The module registers OutboxModule, the store, and an analytics client. forRootAsync() takes the transport classes (transports) and the modules they need (imports) at its top level and the remaining options from useFactory:

outbox-demo.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { ClientProxyTransport, OutboxModule, OutboxStorage } from '@nestjs/outbox';
import { fromDrizzle, PostgresOutboxStore } from '@nestjs/outbox/postgres';
import { createDatabase, DATABASE, migrateOrders, type Database } from './database.js';
import { MailerService, OrderEmailsHandler } from './handlers.js';
import { OrdersService } from './orders.service.js';

export const ANALYTICS_SERVICE = 'ANALYTICS_SERVICE';

@Module({
  imports: [
    OutboxModule.forRootAsync({
      imports: [
        ClientsModule.registerAsync([
          {
            name: ANALYTICS_SERVICE,
            // Read at startup, not at import time.
            useFactory: () => ({
              transport: Transport.TCP,
              options: { host: '127.0.0.1', port: Number(process.env.ANALYTICS_PORT ?? 4001) },
            }),
          },
        ]),
      ],
      transports: { analytics: ClientProxyTransport(ANALYTICS_SERVICE) },
      useFactory: () => ({
        // Each message goes to exactly one transport: `local` runs @OnOutboxMessage() handlers.
        route: (message) => (message.topic.startsWith('analytics.') ? 'analytics' : 'local'),
        relay: { enabled: process.env.OUTBOX_RELAY !== 'off', pollInterval: '1s', lease: '30s', publishTimeout: '10s' },
        retry: { attempts: 5, backoff: { delay: '1s', maxDelay: '1m' } },
      }),
    }),
  ],
  providers: [
    {
      provide: DATABASE,
      useFactory: async () => {
        const { db } = createDatabase();
        await migrateOrders(db);
        return db;
      },
    },
    {
      // Messages, dead letters and inbox records in your database, in the nest_outbox schema.
      provide: PostgresOutboxStore,
      inject: [DATABASE, OutboxStorage],
      useFactory: (db: Database, outboxStorage: OutboxStorage) =>
        new PostgresOutboxStore({ executor: fromDrizzle(db) }, outboxStorage),
    },
    MailerService,
    OrderEmailsHandler,
    OrdersService,
  ],
  exports: [OrdersService],
})
export class OutboxDemoModule {}

Points worth knowing:

  • PostgresOutboxStore takes an executor that runs its SQL through the client you already have. fromDrizzle(db) works with any Drizzle PostgreSQL driver, including PGlite; fromPg(pool), fromTypeOrm(dataSource), fromPrisma(prisma) and fromKysely(db) do the same for node-postgres, TypeORM, Prisma and Kysely. MySqlOutboxStore from @nestjs/outbox/mysql supports MySQL 8.4 LTS and 9.x (MariaDB is refused). At startup the module logs OutboxStorage: PostgresOutboxStore.

  • route picks exactly one transport per message, so the same event for two destinations is two messages. local is built in and reserved.

  • relay.enabled: false turns the polling loop off in processes that only produce or only consume, and in tests that drive the relay themselves.

  • OutboxModule is global: Outbox, OutboxRelay, OutboxEvents, OutboxDeadLetters and OutboxInbox can be injected anywhere.

  • Read the environment through ConfigService in an application (see configuration); the example reads process.env to stay short.

  • In production, apply the store’s migrations before the new version starts, with npx nest-outbox migrate (status exits with 1 while the schema is behind; sql prints the SQL for your own migration tool). With NODE_ENV=production the store does not migrate at startup, and a schema that is behind fails startup with an OutboxSchemaError.

Table 1. OutboxModule options (defaults from the official reference)
Option Default Meaning

transports, imports

None

Named transport classes or instances, and the modules whose exports they inject.

route

The only destination

(message) ⇒ name: the one transport a message goes to. Required with handlers plus transports, or several transports.

relay.enabled

true

false in instances that only produce or only consume.

relay.pollInterval

'1s'

Wait between polls when idle.

relay.batchSize

100

Messages per claim.

relay.lease

'30s'

How long a claim is exclusive.

relay.concurrency

10

Key groups published in parallel within a batch.

relay.publishTimeout

A third of lease

A slower publish counts as a failed attempt and its signal aborts.

retry.attempts

20

Total attempts, including the first, before the message is dead-lettered. retry: false means a single attempt.

retry.backoff

'1s' doubling to '5m', equal jitter

An object (delay, factor, maxDelay, jitter) or a function.

retry.retryIf

Every error

(error, attempt, message) ⇒ boolean: false dead-letters at once.

allowInMemoryStorage

false

Allow the in-memory store with NODE_ENV=production.

isGlobal

true

Register the module globally.

Durations are milliseconds or strings such as '30s'. The tutorial on the official page uses 10 attempts capped at one minute; the package default is 20 attempts, about 30 to 60 minutes in total. Size retry to how long your downstream systems can be down, because a dead-lettered message stops holding back its key.

Writing messages in the same transaction

orders.service.ts
import { randomUUID } from 'node:crypto';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { Outbox } from '@nestjs/outbox';
import { DATABASE, orders, type Database, type Transaction } from './database.js';

export interface Order {
  id: string;
  userId: string;
  total: number;
  status: 'placed' | 'cancelled';
}

@Injectable()
export class OrdersService {
  constructor(
    @Inject(DATABASE) private readonly db: Database,
    private readonly outbox: Outbox<Transaction>,
  ) {}

  async placeOrder(userId: string, total: number, failAfterAdd = false): Promise<Order> {
    if (total <= 0) {
      throw new BadRequestException('The total must be positive');
    }
    const order: Order = { id: randomUUID(), userId, total, status: 'placed' };

    await this.db.transaction(async (tx) => {
      await tx.insert(orders).values(order);

      // The messages are rows written through the same tx: they commit or roll back with the order.
      await this.outbox.add(tx, [
        { topic: 'order.placed', payload: order },
        { topic: 'analytics.order.placed', key: order.id, payload: order },
      ]);

      if (failAfterAdd) {
        throw new Error('Connection terminated unexpectedly');
      }
    });

    this.outbox.notify(); // wake the relay now instead of waiting for the next poll
    return order;
  }
}

add(tx, messageOrMessages) takes the transaction first. With Drizzle it is the tx that db.transaction() hands its callback (Outbox<Transaction> types it); with TypeORM it is the EntityManager from dataSource.transaction(); with Prisma, the interactive transaction client; with Kysely, the transaction. Pass the transaction, never the database: the database would write the message on its own, which is the dual write again, and the store refuses it with OutboxTransactionRequiredError. Message fields:

  • topic and payload are required. The payload is serialized when add() runs, so later changes to the object are not published.

  • key is the ordering key: messages that share it are published one at a time, in the order their transactions committed, even across instances. A retrying message holds back the rest of its key until it succeeds or is dead-lettered. Use one key per ordered stream, and do not share a key between messages for different transports (a mail outage would otherwise block analytics events).

  • headers, id (default a time-ordered UUIDv7, which consumers deduplicate by), and delay or availableAt (do not publish before then).

notify() wakes the relay after the commit; without it the message goes out at the next poll, up to pollInterval later. @OnOutboxMessage() handlers must live on singleton providers: a handler on a request-scoped provider fails at startup.

Handlers, inboxes and the local transport

handlers.ts
import { Injectable } from '@nestjs/common';
import { NonRetryableMessageError, OnOutboxMessage } from '@nestjs/outbox';
import type { Order } from './orders.service.js';

/** Stand-in for an email provider's SDK. */
@Injectable()
export class MailerService {
  readonly sent: string[] = [];
  failTimes = 0;

  async sendOrderConfirmation(order: Order): Promise<void> {
    if (this.failTimes > 0) {
      this.failTimes--;
      throw new Error('mail provider unavailable');
    }
    this.sent.push(order.id);
  }
}

@Injectable()
export class OrderEmailsHandler {
  constructor(private readonly mailer: MailerService) {}

  // The consumer name identifies this handler's inbox: keep it stable.
  @OnOutboxMessage('order.placed', { consumer: 'order-confirmation-email' })
  async sendConfirmation(order: Order) {
    if (order.total > 1_000_000) {
      // Retrying will not make it valid: dead-letter at once, reason "rejected".
      throw new NonRetryableMessageError(`Order ${order.id} exceeds the mail provider limit`);
    }
    await this.mailer.sendOrderConfirmation(order);
  }
}

consumer is required and must stay stable: it names the handler’s inbox, and a new name makes every past message look new. Before calling the handler the local transport checks whether this consumer has already processed the message id, and records the id after the handler succeeds. That leaves a gap: if the process dies after the email is sent and before the id is recorded, the email is sent again. For effects that live in your own database, close the gap with ctx.processInTransaction(tx, work) in the handler’s own transaction: the inbox record and your writes commit together, so a redelivery finds the record and skips work.

Handler style Behavior Guarantee

Default

Skips the handler if the inbox has the id, records it after the handler succeeds

Once, unless the process dies between the handler finishing and the record, or another instance delivers the same message at the same time

ctx.processInTransaction(tx, work)

Records the id in the handler’s transaction with its writes

Exactly once for writes to the same database

inbox: false

Every delivery runs the handler

At least once

A handler gets the payload and a context with message (id, topic, key, headers), consumer, attempt, signal and processInTransaction(). The relay allows publishTimeout per attempt; pass ctx.signal to the calls the handler makes (the signal option of fetch(), for example), because a handler that ignores it keeps running after the attempt counted as failed. When several handlers fail, the message is retried and the handlers that succeeded are skipped by their inboxes.

Publishing to a microservice

ClientProxyTransport(token) publishes through the ClientProxy registered under that name, which is why ClientsModule is in the module’s imports. It calls client.emit(topic, envelope), the call you would make without an outbox; the envelope has id, topic, key, headers, createdAt and payload. The receiving side is an ordinary event handler (see microservices fundamentals):

import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';

@Controller()
export class AnalyticsController {
  @EventPattern('analytics.order.placed')
  record(@Payload() envelope: { id: string; key: string; payload: unknown }) {
    // Deduplicate by envelope.id in the analytics service's own database.
  }
}

The toPacket(message, envelope) option of ClientProxyTransport lets you shape the pattern and data yourself. For other brokers, write an OutboxTransport of your own, for instance one that calls queue.add(topic, payload, { jobId: message.id }) to publish to BullMQ. The official page explains why BullMQ’s own PostgreSQL backend cannot yet be the outbox: its add() cannot join your transaction.

Retries and the dead-letter queue

When a publish fails (a handler threw, the connection was refused, or publishTimeout passed), the relay reschedules the message with exponential backoff, randomized between half and the full delay so retries do not arrive in lockstep. After retry.attempts failures the message moves to the dead-letter table with reason exhausted and the error of every attempt. A NonRetryableMessageError, or an error for which retry.retryIf returns false, dead-letters it at once with reason rejected. A topic with no handler in the process is retried, not dead-lettered, so a rolling deploy does not lose messages that only the new version handles.

OutboxDeadLetters lists, inspects, requeues and purges dead letters. The package mounts no HTTP routes; expose it behind your own authorization, because dead letters contain full payloads:

import { Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from '@nestjs/common';
import { OutboxDeadLetters } from '@nestjs/outbox';
import { AdminGuard } from './admin.guard.js'; // your real authorization: dead letters hold payloads

@Controller('admin/outbox')
@UseGuards(AdminGuard)
export class OutboxAdminController {
  constructor(private readonly deadLetters: OutboxDeadLetters) {}

  @Get('dead-letters')
  list(@Query('topic') topic?: string) {
    return this.deadLetters.list({ topic });
  }

  @Post('dead-letters/:id/requeue')
  @HttpCode(HttpStatus.OK)
  async requeue(@Param('id') id: string) {
    return { requeued: await this.deadLetters.requeue(id) };
  }

  @Delete('dead-letters/:id')
  async purge(@Param('id') id: string) {
    return { purged: await this.deadLetters.purge(id) };
  }
}

requeue() moves a dead letter back with a fresh retry budget and the same id, so consumers that already processed it skip it through their inbox: after you restock, requeueing an out-of-stock order reserves the stock without sending a second email. Both methods also take an array of ids or a filter by topic, key or failedBefore, and refuse an empty filter unless all: true is passed.

Several instances, lifecycle and delivery guarantees

Every instance runs its relay against the same tables. A relay claims a batch by writing a lease with a random token (SKIP LOCKED passes over rows another relay is writing); every later write for a message (published, rescheduled, dead-lettered) must present that token, so a relay that stalled past its lease cannot overwrite the work of the one that took over. A relay never starts a publish with less than publishTimeout of lease left. Leases use the relay’s clock, so keep hosts in sync with NTP. If an instance dies holding a lease, another publishes its messages after the lease expires.

In lifecycle terms (see lifecycle events and shutdown), the store migrates and registers during module initialization, the relay starts after the store has migrated and registered, and with app.enableShutdownHooks() called it is stopped and drained on SIGTERM, so a deploy does not leave messages leased until the lease expires. Neither package uses the HTTP adapter: the Express and Fastify differences only concern your own controllers (such as the webhook that sends a signal), not the relay, the worker or the stores.

Situation Outcome

The transaction rolls back

The message never existed

The transaction commits

Published eventually, or dead-lettered with its error history

A relay dies after claiming

Published by another instance once the lease expires

A relay dies after publishing, before recording it

Published twice; the inboxes drop the duplicate

NonRetryableMessageError or retryIf returns false

Dead-lettered at once (rejected)

Messages share a key

Published in commit order; a retrying message holds back the rest of its key until it succeeds or is dead-lettered

TCP, Redis or core NATS transport

"Published" means the write left the process: a consumer crash afterwards loses the message

The overall guarantee is at least once, with the consumers' inboxes absorbing duplicates. Observe it with OutboxRelay.stats() (pending, ready, leased, deadLetters, oldestDueAt, lagMs, inFlight) and OutboxEvents.events$, which also publishes on node:diagnostics_channel channels nestjs:outbox:published, retry-scheduled, dead-lettered and lease-lost. Alert on lagMs, deadLetters and dead-lettered events. Nothing prunes the inboxes for you: call OutboxInbox.prune('30d') from a scheduled job (see events, scheduling and queues) with a window longer than any redelivery.

Testing the outbox

Turn the polling loop off and drive the relay yourself: relay.runOnce() claims and processes one batch and resolves with claimed, published, retried, deadLettered, released and leaseLost. The test below starts a real Nest TCP microservice for the analytics side and uses PGlite for the database:

outbox.e2e-spec.ts
import { Controller, Module, type INestMicroservice } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { EventPattern, Payload, Transport } from '@nestjs/microservices';
import { OutboxDeadLetters, OutboxRelay } from '@nestjs/outbox';
import { Test, type TestingModule } from '@nestjs/testing';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { MailerService } from '../src/outbox-workflows/handlers.js';
import { OrdersService } from '../src/outbox-workflows/orders.service.js';
import { OutboxDemoModule } from '../src/outbox-workflows/outbox-demo.module.js';

const received: { topic: string; key: string }[] = [];

// A real Nest microservice over TCP, standing in for the analytics service.
@Controller()
class AnalyticsController {
  @EventPattern('analytics.order.placed')
  record(@Payload() envelope: { topic: string; key: string }) {
    received.push(envelope);
  }
}
@Module({ controllers: [AnalyticsController] })
class AnalyticsModule {}

describe('outbox', () => {
  let moduleRef: TestingModule;
  let analytics: INestMicroservice;
  let orders: OrdersService;
  let relay: OutboxRelay;
  let mailer: MailerService;

  beforeAll(async () => {
    process.env.OUTBOX_RELAY = 'off'; // no poll loop: the test drives the relay
    process.env.ANALYTICS_PORT = '47011';
    analytics = await NestFactory.createMicroservice(AnalyticsModule, {
      transport: Transport.TCP,
      options: { host: '127.0.0.1', port: 47011 },
      logger: false,
    });
    await analytics.listen();
    moduleRef = await Test.createTestingModule({ imports: [OutboxDemoModule] }).compile();
    await moduleRef.init();
    orders = moduleRef.get(OrdersService);
    relay = moduleRef.get(OutboxRelay);
    mailer = moduleRef.get(MailerService);
  });

  afterAll(async () => {
    await moduleRef.close();
    await analytics.close();
  });

  it('publishes only after the commit, to the handler and to the microservice', async () => {
    const order = await orders.placeOrder('user-1', 2499);
    expect(mailer.sent).toEqual([]); // nothing is published inside the transaction
    expect(await relay.stats()).toMatchObject({ pending: 2 });

    expect(await relay.runOnce()).toMatchObject({ claimed: 2, published: 2 });

    expect(mailer.sent).toEqual([order.id]);
    await vi.waitFor(() => expect(received).toHaveLength(1));
    expect(received[0]).toMatchObject({ topic: 'analytics.order.placed', key: order.id });
    expect(await relay.stats()).toMatchObject({ pending: 0, deadLetters: 0 });
  });

  it('writes nothing when the transaction rolls back', async () => {
    await expect(orders.placeOrder('user-1', 100, true)).rejects.toThrow('Connection terminated');
    expect(await relay.stats()).toMatchObject({ pending: 0 });
    expect(await relay.runOnce()).toMatchObject({ claimed: 0 });
  });

  it('retries a failed handler with backoff', async () => {
    mailer.failTimes = 1;
    const now = Date.now();
    const clock = vi.spyOn(Date, 'now'); // the relay and the store read the time from Date.now()
    await orders.placeOrder('user-2', 500);

    expect(await relay.runOnce()).toMatchObject({ retried: 1 });
    clock.mockReturnValue(now + 10_000); // move past the backoff
    expect(await relay.runOnce()).toMatchObject({ published: 1, retried: 0 });
    clock.mockRestore();

    expect(await relay.stats()).toMatchObject({ pending: 0, deadLetters: 0 });
  });

  it('dead-letters a non-retryable failure and requeues it', async () => {
    await orders.placeOrder('user-3', 2_000_000);
    expect(await relay.runOnce()).toMatchObject({ deadLettered: 1 });

    const deadLetters = moduleRef.get(OutboxDeadLetters);
    const [letter] = await deadLetters.list({ topic: 'order.placed' });
    expect(letter.history).toHaveLength(1);
    expect(await deadLetters.requeue(letter.id)).toBeTruthy();
    expect((await relay.stats()).pending).toBeGreaterThan(0);
  });
});

The tests above follow the sequence the page describes: after placeOrder() the outbox holds two pending messages and the mailer has sent nothing; one runOnce() publishes both, and the TCP service receives the envelope for analytics.order.placed keyed by the order id; the rolled-back order leaves no message behind; the first attempt of a failing mailer is a retry-scheduled event and, after the clock moves forward, the second attempt publishes it; and a payload rejected with NonRetryableMessageError goes straight to the dead-letter table with one entry in its history, after which requeue() puts it back. To keep the outbox out of a unit test, replace the store with new InMemoryOutboxStore() through overrideProvider(PostgresOutboxStore); it cannot join your transactions, so a rolled-back message is still published, and the store logs a warning the first time it is handed a transaction.

Durable workflows: @nestjs/workflows

The model

A workflow is an injectable class with a @Workflow(name) decorator and a run(ctx, input) method. Each side effect runs in a journaled ctx.step(). Whenever the instance resumes (after a wait, a sleep or a crash), run() executes again from the top; completed steps return their recorded results instead of running twice. Waits and sleeps park the instance in your database, and any process on any host can pick it up when it is due. There is no workflow server: the worker runs inside your application process, polls once a second, claims due instances under a lease and renews the lease every third of leaseDuration. A crashed process’s instances become claimable again when the lease expires.

npm i --save @nestjs/workflows

As with the outbox, instances, journals and signals live in a store registered as a provider (PostgresWorkflowStore from @nestjs/workflows/postgres, or MySqlWorkflowStore), in a schema of its own (nest_workflows), migrated at startup in development and with npx nest-workflows migrate in production. Without a store the module keeps everything in memory, and with NODE_ENV=production startup refuses that. The module and the store:

workflows-demo.module.ts
import { Module } from '@nestjs/common';
import { WorkflowsModule, WorkflowStorage } from '@nestjs/workflows';
import { fromDrizzle, PostgresWorkflowStore } from '@nestjs/workflows/postgres';
import { createDatabase, DATABASE, migrateOrders, type Database } from '../database.js';
import { OrderFulfilmentWorkflow } from './order-fulfilment.workflow.js';
import { InventoryService, MailService, PaymentsService } from './services.js';

@Module({
  imports: [
    WorkflowsModule.forRoot({
      worker: {
        concurrency: 10, // instances this process executes at once
        leaseDuration: '30s', // how long a crashed process keeps its instances
        shutdownTimeout: '10s', // how long a deploy waits for running steps
      },
    }),
  ],
  providers: [
    {
      provide: DATABASE,
      useFactory: async () => {
        const { db } = createDatabase();
        await migrateOrders(db);
        return db;
      },
    },
    {
      // Instances, journals and signals in your database, in the nest_workflows schema.
      provide: PostgresWorkflowStore,
      inject: [DATABASE, WorkflowStorage],
      useFactory: (db: Database, workflowStorage: WorkflowStorage) =>
        new PostgresWorkflowStore({ executor: fromDrizzle(db) }, workflowStorage),
    },
    PaymentsService,
    InventoryService,
    MailService,
    OrderFulfilmentWorkflow,
  ],
  exports: [DATABASE, PaymentsService, InventoryService, MailService],
})
export class WorkflowsDemoModule {}

worker: false (or worker.enabled: false) turns the polling loop off in processes that should only start, signal and inspect workflows, such as API pods; WorkflowWorker.drain() runs due instances on demand, which is how the tests below work. Call app.enableShutdownHooks() so that on SIGTERM the worker stops claiming, aborts the signal of running steps, waits up to shutdownTimeout and hands its instances back for another process to pick up at once. Keep shutdownTimeout below your platform’s grace period.

Writing a workflow

Steps run at least once: if the process dies after the payment provider charged the card and before the result reached the journal, the step runs again. The engine therefore hands each step an idempotencyKey that stays the same across attempts and processes (<instance id>:<step name>); pass it to the system you call. The stand-ins below deduplicate by key as a real payment provider or warehouse would:

services.ts
import { Injectable } from '@nestjs/common';
import { NonRetryableStepError } from '@nestjs/workflows';
import type { Order } from './order.js';

/** In-memory stand-ins for a payment provider, a warehouse and a mail provider. They deduplicate by idempotency key. */
@Injectable()
export class PaymentsService {
  readonly charges = new Map<string, number>();
  readonly refunds = new Set<string>();

  async charge(order: Order, idempotencyKey: string) {
    if (order.total > 100_000) {
      throw new NonRetryableStepError(`The payment provider declined order ${order.id}.`);
    }
    this.charges.set(idempotencyKey, order.total); // a repeated key returns the first charge
    return { chargeId: `ch_${idempotencyKey}`, amount: order.total };
  }

  async refund(chargeId: string, idempotencyKey: string) {
    this.refunds.add(idempotencyKey);
  }
}

@Injectable()
export class InventoryService {
  stock = new Map<string, number>([['kibble', 10]]);
  readonly reservations = new Map<string, Order['items']>();

  async reserve(items: Order['items'], idempotencyKey: string) {
    if (this.reservations.has(idempotencyKey)) {
      return { reservationId: idempotencyKey };
    }
    for (const item of items) {
      if ((this.stock.get(item.productId) ?? 0) < item.quantity) {
        throw new NonRetryableStepError(`Not enough stock for "${item.productId}"`);
      }
    }
    for (const item of items) {
      this.stock.set(item.productId, this.stock.get(item.productId)! - item.quantity);
    }
    this.reservations.set(idempotencyKey, items);
    return { reservationId: idempotencyKey };
  }

  async release(reservationId: string) {
    for (const item of this.reservations.get(reservationId) ?? []) {
      this.stock.set(item.productId, this.stock.get(item.productId)! + item.quantity);
    }
    this.reservations.delete(reservationId);
  }
}

@Injectable()
export class MailService {
  readonly reviewRequests: string[] = [];

  async sendReviewRequest(order: Order, idempotencyKey: string) {
    this.reviewRequests.push(idempotencyKey);
  }
}

The signals, defined once and shared by sender and workflow, and the workflow itself:

carrier-events.ts
import { WorkflowSignal } from '@nestjs/workflows';
import type { CarrierEvent } from './order.js';

/** One definition, shared by the sender and the workflow: neither can misspell the name or the payload. */
export const shipmentDelivered = new WorkflowSignal<CarrierEvent>('shipment.delivered');
export const shipmentReturned = new WorkflowSignal<CarrierEvent>('shipment.returned');
order-fulfilment.workflow.ts
import { Workflow, type WorkflowContext, type WorkflowRunner } from '@nestjs/workflows';
import { shipmentDelivered, shipmentReturned } from './carrier-events.js';
import type { FulfilmentResult, Order } from './order.js';
import { InventoryService, MailService, PaymentsService } from './services.js';

/** One fulfilment per order: the order id is the workflow instance id. */
export const fulfilmentId = (orderId: string) => `order-${orderId}`;

@Workflow('order-fulfilment', { timeout: '30d' })
export class OrderFulfilmentWorkflow implements WorkflowRunner<Order, FulfilmentResult> {
  constructor(
    private readonly payments: PaymentsService,
    private readonly inventory: InventoryService,
    private readonly mail: MailService,
  ) {}

  async run(ctx: WorkflowContext, order: Order): Promise<FulfilmentResult> {
    const charge = await ctx.step(
      'charge-payment',
      ({ idempotencyKey }) => this.payments.charge(order, idempotencyKey),
      {
        retry: { attempts: 5, backoff: { delay: '2s' } },
        // Runs, in reverse order, if the workflow later fails or is cancelled.
        compensate: (charge, { idempotencyKey }) => this.payments.refund(charge.chargeId, idempotencyKey),
      },
    );
    ctx.setStatus('paid');

    await ctx.step(
      'reserve-stock',
      ({ idempotencyKey }) => this.inventory.reserve(order.items, idempotencyKey),
      { compensate: ({ reservationId }) => this.inventory.release(reservationId) },
    );
    ctx.setStatus('awaiting-delivery');

    // Parks the instance in the database: no memory, timer or worker is held for up to 3 days.
    const outcome = await ctx.waitForAny('await-delivery', {
      delivered: ctx.signalWait(shipmentDelivered, { key: order.id }),
      returned: ctx.signalWait(shipmentReturned, { key: order.id }),
      timedOut: ctx.timer('3d'),
    });
    if (outcome.key === 'returned') {
      ctx.fail(`The carrier returned order ${order.id} to the warehouse.`);
    }
    if (outcome.key === 'timedOut') {
      ctx.fail(`Order ${order.id} was not delivered within 3 days.`);
    }
    const delivery = outcome.value; // narrowed to a CarrierEvent

    // Point of no return: nothing from here on refunds the charge or releases the stock.
    ctx.commit('delivered');
    ctx.setStatus('delivered');

    await ctx.sleep('before-review-request', '7d');
    await ctx.step('send-review-request', ({ idempotencyKey }) => this.mail.sendReviewRequest(order, idempotencyKey));

    return { chargeId: charge.chargeId, trackingNumber: delivery.trackingNumber };
  }
}

What each call does:

  • ctx.step(name, fn, options). Runs fn once and journals the result. Results must be JSON-serializable and come back in JSON form on the first run too (a Date is a string); the return type is Journaled<T>, so TypeScript flags code that treats a journaled value as a live object. retry takes attempts (the first attempt counts), backoff (delay, factor, maxDelay, jitter) or a number; the module default is 3 attempts with a backoff from 1 second. A NonRetryableStepError fails the step at once. A failed attempt parks the instance until the backoff has elapsed, so retries survive restarts. timeout bounds an attempt and heartbeatTimeout an attempt that stops calling heartbeat().

  • compensate. Registered when the step completes. If the workflow later fails or is cancelled, compensations of completed steps run in reverse order, each journaled and retried with its own idempotency key. This is the saga’s undo, and it survives a crash.

  • ctx.waitForSignal(name, signal, { key, timeout }). Parks the instance until a signal with that name and correlation key arrives, or resolves with null after the timeout. Signals are durable: one that arrives before the workflow reaches the wait still counts, and each is consumed at most once per instance.

  • ctx.waitForAny(name, conditions). Waits on ctx.signalWait() and ctx.timer() conditions together and resolves with the key of the first one and its value; checking key narrows value. The winner is journaled, so every replay sees the same one.

  • ctx.sleep(name, duration). A durable timer. After seven days any worker resumes the instance, never early, and run() replays so charge and delivery are still in scope; a delayed job cannot give you that continuation.

  • ctx.commit(label). The point of no return: it discards the compensations registered so far, so nothing after it refunds the charge or releases the stock, neither a failure nor a cancel.

  • ctx.fail(message). Fails the instance and runs the compensations. Inside a step, throw NonRetryableStepError instead.

  • ctx.setStatus(value). A custom status (JSON, up to 16 KiB) that getStatus() and list() return as customStatus. It is not journaled and is written with the instance’s next write.

  • ctx.now(), ctx.random(), ctx.uuid(). Journaled replacements for Date.now(), Math.random() and randomUUID().

@Workflow(name, { timeout }) bounds the whole instance: the deadline is stored with it, a parked instance wakes for it, and when it passes the compensations run and the instance ends as failed with a WorkflowTimeoutError. Other @Workflow() options are version, concurrency (a limit, optionally per key function, held only while an execution runs, not while the instance sleeps), rateLimit (max per duration, optionally per key) and schedules.

Rules for workflow code

run() executes again from the top on every resume, so code between steps must decide the same way each time:

  • Put anything that reads or touches the outside world in a step: HTTP calls, database reads and writes, feature flags.

  • Outside steps use ctx.now(), ctx.random() and ctx.uuid(). Only await ctx operations and pure computation in run(), never a real timer.

  • Step, sleep and wait names must be unique within a run; in a loop add the index (remind-1, remind-2). Give children started from parallel branches an id option derived from your data.

  • Do not call ctx methods inside a step function; the engine fails the instance with a WorkflowDefinitionError.

  • If you catch errors, rethrow what you do not handle (isWorkflowInterrupt() identifies the engine’s control flow), and keep side effects out of finally blocks, which run at every suspension.

  • Promise.all() over steps is fine; Promise.race() and Promise.any() are not replay-safe. Use waitForAny() to race conditions.

  • Keep no per-run state on this: one instance of the class serves every order.

Starting, signalling and cancelling

WorkflowClient starts instances, sends signals, reads status and operates on running ones. Starting an order’s fulfilment in the same transaction as the order, with the instance id derived from the order id, gives two properties at once: the fulfilment exists if and only if the order does, and calling start() again with the same id and input returns the existing instance with created: false (a different input throws WorkflowIdConflictError, status 409).

orders-tx.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { WorkflowClient } from '@nestjs/workflows';
import { DATABASE, orders, type Database } from '../database.js';
import { fulfilmentId, OrderFulfilmentWorkflow } from './order-fulfilment.workflow.js';
import type { Order } from './order.js';

@Injectable()
export class OrdersTxService {
  constructor(
    @Inject(DATABASE) private readonly db: Database,
    private readonly workflowClient: WorkflowClient,
  ) {}

  async place(order: Order, fail = false) {
    return this.db.transaction(async (tx) => {
      await tx.insert(orders).values({ id: order.id, userId: order.userId, total: order.total, status: 'placed' });
      // Same transaction: the fulfilment exists if and only if the order does.
      const fulfilment = await this.workflowClient.start(OrderFulfilmentWorkflow, order, {
        id: fulfilmentId(order.id),
        transaction: tx,
        priority: 2, // lower runs first
      });
      if (fail) {
        throw new Error('insert or update violates foreign key constraint');
      }
      return { ...order, fulfilment };
    });
  }
}

The worker only sees committed instances: it picks this one up at its next poll after the commit. With the PostgreSQL store, a transaction must be a READ COMMITTED transaction (the default); the store refuses stricter levels and also refuses the database object itself, because a write outside the transaction would be the dual write again. The same transaction option exists on signal(): a webhook can update the order and wake the workflow in one transaction.

// In a webhook handler: the order's new status and the signal commit together.
await this.db.transaction(async (tx) => {
  await tx.update(orders).set({ status: 'delivered' }).where(eq(orders.id, event.reference));
  await this.workflowClient.signal(shipmentDelivered, event, { key: event.reference, transaction: tx });
});

Return 2xx to the sender only after that transaction has committed, verify the sender’s signature first, and give a signal an id when its sender can run twice (a retried webhook): a signal with the same id is stored once while it remains stored.

Operating on instances:

  • getStatus(id, { journal, children }) returns the instance: status, customStatus, wakeAt, the signals it waits for, output or error, and the journal if asked.

  • cancel(id, reason) sets a flag: a parked instance wakes at once, a running one stops at its next ctx call, and the compensations of completed steps run in reverse before it ends as cancelled. It returns accepted: false if the instance already ended. A cancel after ctx.commit() undoes nothing.

  • retry(id) re-runs a failed instance from its journal (not one whose compensations ran, since resuming would build on undone work), delete(id) removes a finished instance (force for an unfinished one, without compensations) and terminate(id, reason) stops an instance without running its compensations. retry() and delete() throw WorkflowStateError (409) where they make no sense.

  • purge({ olderThan }) deletes finished instances and the signals nobody can take any more. Finished instances stay until you purge them, so run it nightly on a single instance of the application, with olderThan longer than any sender’s redelivery window.

  • startAndWait() starts and waits for the result; result(id, { timeout }) waits for an existing instance. Past the timeout it rejects with WorkflowResultTimeoutError and the instance keeps running.

Status Meaning

pending

Started and not yet claimed; waiting for a concurrency slot, a rate-limit window or a worker that registers its version

running

A worker holds its lease and executes it

suspended

Parked on a sleep, a wait or a retry backoff until wakeAt or a signal

compensating

Undoing completed steps after a failure or cancel

completed, cancelled

Done; completed has the output of run()

failed

A step gave up, ctx.fail() was called, the workflow’s run timeout passed, or the journal hit its limit, and then the compensations ran (if none ran, fix the cause and retry())

compensation_failed

An undo gave up halfway; fix the cause and retry(), or finish by hand and delete()

Child workflows

A sub-process with its own steps, retries and status can be a workflow of its own, run as a child of another. ctx.executeChild() starts the child and waits for its end; both are journaled ($child:<id>, $result:<id>), so a replay finds the same child instead of starting another. ctx.startChild() only starts it and returns a handle whose result() waits and which can be a waitForAny() condition.

shipment.workflow.ts
import { Workflow, type WorkflowContext, type WorkflowRunner } from '@nestjs/workflows';
import { shipmentDelivered, shipmentReturned } from './carrier-events.js';
import type { CarrierEvent, Order } from './order.js';

/** Waits for the carrier: a child workflow with its own steps, retries and status. */
@Workflow('shipment')
export class ShipmentWorkflow implements WorkflowRunner<Order, CarrierEvent> {
  async run(ctx: WorkflowContext, order: Order): Promise<CarrierEvent> {
    await ctx.step('book-pickup', async () => ({ pickupId: `pickup-${order.id}` }));

    const outcome = await ctx.waitForAny('await-delivery', {
      delivered: ctx.signalWait(shipmentDelivered, { key: order.id }),
      returned: ctx.signalWait(shipmentReturned, { key: order.id }),
      timedOut: ctx.timer('3d'),
    });
    if (outcome.key !== 'delivered') {
      ctx.fail(`Order ${order.id} was not delivered (${outcome.key}).`);
    }
    return outcome.value;
  }
}

@Workflow('order-with-shipment')
export class OrderWithShipmentWorkflow implements WorkflowRunner<Order, string> {
  async run(ctx: WorkflowContext, order: Order): Promise<string> {
    await ctx.step('charge', async () => ({ ok: true }), { compensate: async () => undefined });
    // Starts the child and waits for it. A failed child throws ChildWorkflowFailedError.
    const delivery = await ctx.executeChild(ShipmentWorkflow, order);
    return delivery.trackingNumber;
  }
}

A child’s id defaults to its parent’s id, the workflow name and a counter (for example p1/shipment#1). If the child fails, is cancelled or is terminated, executeChild() throws ChildWorkflowFailedError with the child’s status and error as cause; uncaught, it fails the parent like any error, and the parent’s compensations run. A parcel nobody delivers in three days fails the child, which then fails the parent with Child workflow "shipment" ("p2/shipment#1") failed: …​ was not delivered (timedOut). parentClose decides what happens to a still-running child when its parent ends or starts compensating: 'cancel' (default), 'terminate' or 'abandon'.

Timers, schedules and long steps

A schedule declared on the workflow starts an instance per occurrence on whichever worker finds it, with the occurrence’s time in the instance id so there is exactly one instance however many workers run. cron (5 or 6 fields), every and rrule set the timing, tz the zone, missed ('skip', 'once', 'all') what happens to occurrences no worker was up for, and overlap ('skip', 'allow', 'cancel-previous', 'buffer-one') what happens while the previous occurrence is unfinished. Schedules can also be created at runtime with workflowClient.schedules.upsert(). A long step keeps its lease renewed in the background and can checkpoint with heartbeat(progress): after a crash the next attempt receives the checkpoint as progress, so a monthly batch resumes at the last page.

@Workflow('invoice-batch', {
  schedules: [
    {
      id: 'monthly-invoices',
      cron: '0 2 1 * *', // 02:00 on the 1st of every month, in the zone below
      tz: 'America/New_York',
      missed: 'once', // a deploy or an outage at 02:00 delays the batch instead of skipping a month
      input: ({ at }) => ({ month: lastMonth(at) }),
    },
  ],
})
export class InvoiceBatchWorkflow implements WorkflowRunner<{ month: string }, { rendered: number }> {
  async run(ctx: WorkflowContext, { month }: { month: string }) {
    return ctx.step(
      'render-invoices',
      async ({ progress, heartbeat, signal }: WorkflowStepContext<{ cursor: number | null; rendered: number }>) => {
        let { cursor, rendered } = progress ?? { cursor: 0, rendered: 0 };
        while (cursor !== null) {
          const page = await this.invoices.renderPage(month, cursor, signal);
          cursor = page.nextCursor;
          rendered += page.rendered;
          await heartbeat({ cursor, rendered }); // the checkpoint a retry receives as `progress`
        }
        return { rendered };
      },
      { heartbeatTimeout: '2m' },
    );
  }
}

Source: NestJS documentation: Durable workflows, "Run workflows on a schedule" and "Long-running steps". This snippet is shown for shape and was not run, unlike the others on this page; lastMonth() and InvoicesService are the official tutorial’s helpers.

Versioning

The journal is keyed by name, so step, sleep and wait names are a contract with every instance still running. Before the first new step of an execution runs, the engine checks that the code reached every step in the journal; if a deploy renamed, removed or reordered steps, the instance fails with a WorkflowNonDeterminismError before charging anything, and its compensations do not run because they are defined by code that no longer matches. Changing a step’s body without changing its meaning, adding steps after the last one any running instance has reached, and changing retry options need no new version. Anything else ships as a new class with version, keeping the old class registered until its instances finish:

order-fulfilment-v2.workflow.ts
import { Workflow, type WorkflowContext, type WorkflowRunner } from '@nestjs/workflows';
import type { FulfilmentResult, Order } from './order.js';
import { InventoryService, MailService, PaymentsService } from './services.js';

// Changing what a replay would see (here, a step inserted before reserve-stock) ships as a new version.
@Workflow('order-fulfilment', { version: 2 })
export class OrderFulfilmentWorkflowV2 implements WorkflowRunner<Order, FulfilmentResult> {
  constructor(
    private readonly payments: PaymentsService,
    private readonly inventory: InventoryService,
    private readonly mail: MailService,
  ) {}

  async run(ctx: WorkflowContext, order: Order): Promise<FulfilmentResult> {
    const charge = await ctx.step('charge-payment', ({ idempotencyKey }) => this.payments.charge(order, idempotencyKey));
    await ctx.step('send-order-confirmation', async () => ({ sent: true }));
    await ctx.step('reserve-stock', ({ idempotencyKey }) => this.inventory.reserve(order.items, idempotencyKey));
    return { chargeId: charge.chargeId, trackingNumber: 'n/a' };
  }
}

An instance keeps the version it started on, and a worker only claims versions it has registered, so old and new pods can run side by side. New instances start on the highest registered version even when the caller passes the old class (the class only identifies the workflow’s name); start(…​, { version: 1 }) pins one. With both versions registered, start(OrderFulfilmentWorkflow, …​) creates a version 2 instance, and with version: 1 a version 1 instance. Limits (concurrency, rateLimit) come from the highest registered version and apply to all versions, so keep them on the new class.

Integration with CQRS

With the CQRS modules (see CQRS), command handlers publish events and sagas react to them, in memory: an event published just before the process stops never reaches them. @nestjs/workflows/cqrs lets events themselves start and signal workflows durably while your code keeps executing commands and publishing events. Import WorkflowsCqrsModule next to CqrsModule.forRoot() and WorkflowsModule, then map events on the workflow class with @StartOn() and @SignalOn():

cqrs-demo.ts
import { Inject, Module } from '@nestjs/common';
import { CommandBus, CommandHandler, CqrsModule, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { WorkflowsModule, WorkflowStorage, Workflow, type WorkflowContext, type WorkflowRunner } from '@nestjs/workflows';
import { fromDrizzle, PostgresWorkflowStore } from '@nestjs/workflows/postgres';
import { SignalOn, StartOn, WorkflowsCqrsModule } from '@nestjs/workflows/cqrs';
import { createDatabase, DATABASE, migrateOrders, orders, type Database } from '../database.js';
import { shipmentDelivered } from './carrier-events.js';
import type { CarrierEvent, Order } from './order.js';

export class OrderPlacedEvent {
  constructor(readonly order: Order) {}
}
export class OrderDeliveredEvent {
  constructor(readonly delivery: CarrierEvent) {}
}
export class PlaceOrderCommand {
  constructor(readonly order: Order, readonly fail = false) {}
}

@Workflow('cqrs-fulfilment')
@StartOn(OrderPlacedEvent, {
  id: (event) => `order-${event.order.id}`, // required: makes a re-published event find its instance
  input: (event) => event.order,
})
@SignalOn(OrderDeliveredEvent, {
  signal: shipmentDelivered,
  key: (event) => event.delivery.reference,
  id: (event) => event.delivery.trackingNumber, // a re-published event stores no second signal
  payload: (event) => event.delivery,
})
export class CqrsFulfilmentWorkflow implements WorkflowRunner<Order, string> {
  async run(ctx: WorkflowContext, order: Order): Promise<string> {
    const delivery = await ctx.waitForSignal('await-delivery', shipmentDelivered, { key: order.id });
    return delivery!.trackingNumber;
  }
}

@CommandHandler(PlaceOrderCommand)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
  constructor(
    @Inject(DATABASE) private readonly db: Database,
    private readonly eventBus: EventBus,
  ) {}

  async execute({ order, fail }: PlaceOrderCommand): Promise<Order> {
    return this.db.transaction(async (tx) => {
      await tx.insert(orders).values({ id: order.id, userId: order.userId, total: order.total, status: 'placed' });
      // The transaction rides along as the dispatcher context: the workflow this event
      // starts commits with the order, or rolls back with it.
      await this.eventBus.publish(new OrderPlacedEvent(order), { transaction: tx });
      if (fail) {
        throw new Error('constraint violated');
      }
      return order;
    });
  }
}

@Module({
  imports: [CqrsModule.forRoot(), WorkflowsModule.forRoot(), WorkflowsCqrsModule],
  providers: [
    {
      provide: DATABASE,
      useFactory: async () => {
        const { db } = createDatabase();
        await migrateOrders(db);
        return db;
      },
    },
    {
      provide: PostgresWorkflowStore,
      inject: [DATABASE, WorkflowStorage],
      useFactory: (db: Database, workflowStorage: WorkflowStorage) =>
        new PostgresWorkflowStore({ executor: fromDrizzle(db) }, workflowStorage),
    },
    PlaceOrderHandler,
    CqrsFulfilmentWorkflow,
  ],
})
export class CqrsDemoModule {}

The second argument of publish() and publishAll() is the CQRS dispatcher context. WorkflowsCqrsModule reads its transaction property and passes it to start() and signal(), so the instance is created through your transaction: a command that throws after publishing OrderPlacedEvent leaves no workflow instance behind, while the successful command leaves one in pending. @StartOn()’s `id is required (a re-published event finds the instance it started), and @SignalOn()’s `id is optional but makes a re-published delivery store no second signal. The module wraps the event bus’s publisher, so a publisher you set through CqrsModule.forRoot() still receives every event after the workflows; replacing EventBus.publisher after startup bypasses the workflows, and startup fails if something does. Your event handlers and sagas still receive every event in memory, and inside a transaction they run before it commits.

Guarantees depend on how and when the event is published:

How the event is published Start or signal If the process dies or a write fails

In your transaction, with the transaction in the dispatcher context

Commits with your writes, or not at all

Nothing is half done; a failed start rejects publish() and your transaction rolls back

After your transaction commits

Written before publish() resolves

A crash between your commit and the publish loses it; republish from a reconciliation job (the ids make that safe)

From an aggregate’s commit() without a transaction

Written outside your transaction, in the background unless awaited

Lost on a crash and left behind on rollback; @nestjs/cqrs 12.1 lets commit() take the dispatcher context

From a workflow step

Once, however often the step runs

A retried step starts nothing new

A step can also execute a command: pass the step’s idempotencyKey along with the command and have the handler give it to the system it calls. A saga that remembers events, such as one that waits for the payment after the order and gives up after an hour, is a workflow in disguise: start the workflow from the first event, map the others to signals and let the workflow own the timeout. A saga that turns one event into one command can stay a saga.

Production notes

  • Keep journals short: every execution loads the whole journal and replays. The module warns at 1,000 entries or 1 MB (journal-large event) and fails the instance at 10,000 entries or 10 MB (journal.warnEntries and journal.maxEntries change that). An endless process continues in a fresh instance instead of looping forever.

  • Encrypt what the store keeps with a payload codec: AesGcmPayloadCodec takes keys from your secret manager through the environment or ConfigService (never literals), with a current key id and older keys listed while anything they encrypted may still be read. Ids, names, signal keys, statuses and times stay readable, so pick ids that carry nothing sensitive.

  • Alert on instances that need a person: compensation_failed, and failed with WorkflowNonDeterminismError. WorkflowClient.list() finds them and WorkflowEvents.events$ (also published on nestjs:workflows:<type> diagnostics channels) feeds logs and metrics.

  • Give signals an id when their sender can run twice, size the connection pool for both requests and workers, keep transactions that send signals short, and apply the store’s migrations (npx nest-workflows migrate, checked in CI with status) before a new version starts.

  • On MySQL, run a transaction that starts or signals again when it fails with a deadlock (error 1213): MySQL rolled all of it back.

Testing workflows with a manual clock

Every timestamp the engine uses (deadlines, retry times, lease expiry, ctx.now()) comes from the module’s clock. A ManualWorkflowClock only moves when you call advance(), and with the worker disabled WorkflowWorker.drain() runs every due instance until none can start. Override WORKFLOWS_MODULE_OPTIONS to install both; a seven-day sleep and a three-day timeout become single calls:

workflows.e2e-spec.ts
import { Test, type TestingModule } from '@nestjs/testing';
import { ManualWorkflowClock, WORKFLOWS_MODULE_OPTIONS, WorkflowClient, WorkflowWorker } from '@nestjs/workflows';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { shipmentDelivered } from '../src/outbox-workflows/wf/carrier-events.js';
import { fulfilmentId } from '../src/outbox-workflows/wf/order-fulfilment.workflow.js';
import type { Order } from '../src/outbox-workflows/wf/order.js';
import { InventoryService, MailService, PaymentsService } from '../src/outbox-workflows/wf/services.js';
import { WorkflowsDemoModule } from '../src/outbox-workflows/wf/workflows-demo.module.js';

const order = (id: string, quantity = 1): Order => ({
  id,
  userId: 'u1',
  items: [{ productId: 'kibble', quantity, price: 2499 }],
  total: 2499 * quantity,
});

describe('order fulfilment workflow', () => {
  let clock: ManualWorkflowClock;
  let moduleRef: TestingModule;
  let client: WorkflowClient;
  let worker: WorkflowWorker;

  beforeEach(async () => {
    clock = new ManualWorkflowClock(); // time only moves when the test says so
    moduleRef = await Test.createTestingModule({ imports: [WorkflowsDemoModule] })
      .overrideProvider(WORKFLOWS_MODULE_OPTIONS)
      .useValue({ clock, worker: { enabled: false, shutdownTimeout: 50 } }) // no polling: drain() runs due instances
      .compile();
    await moduleRef.init();
    client = moduleRef.get(WorkflowClient);
    worker = moduleRef.get(WorkflowWorker);
  });

  afterEach(() => moduleRef.close());

  it('completes across a signal and a 7-day sleep', async () => {
    const o = order('a1');
    await client.start('order-fulfilment', o, { id: fulfilmentId(o.id) });
    await worker.drain(); // charge, reserve, then park

    let instance = await client.getStatus(fulfilmentId(o.id), { journal: true });
    expect(instance).toMatchObject({ status: 'suspended', customStatus: 'awaiting-delivery' });
    expect(instance!.waits.map((wait) => wait.signal)).toEqual(['shipment.delivered', 'shipment.returned']);

    await client.signal(shipmentDelivered, { type: 'shipment.delivered', reference: o.id, trackingNumber: 'TRK1' }, { key: o.id });
    await worker.drain(); // takes the signal, commits, parks on the 7-day sleep
    expect(await client.getStatus(fulfilmentId(o.id))).toMatchObject({ status: 'suspended', customStatus: 'delivered' });

    clock.advance('7d');
    await worker.drain();
    instance = await client.getStatus(fulfilmentId(o.id), { journal: true });
    expect(instance).toMatchObject({ status: 'completed', output: { trackingNumber: 'TRK1' } });
    expect(moduleRef.get(MailService).reviewRequests).toHaveLength(1);
  });

  it('refunds and releases the stock when the carrier never delivers', async () => {
    const o = order('a2');
    await client.start('order-fulfilment', o, { id: fulfilmentId(o.id) });
    await worker.drain();

    clock.advance('3d');
    await worker.drain(); // the wait times out and the compensations run

    const instance = await client.getStatus(fulfilmentId(o.id), { journal: true });
    expect(instance).toMatchObject({ status: 'failed', error: { message: `Order ${o.id} was not delivered within 3 days.` } });
    expect(instance!.journal.filter((entry) => entry.kind === 'compensation').map((entry) => entry.name)).toEqual([
      '$compensate:reserve-stock', // reverse order
      '$compensate:charge-payment',
    ]);
    expect(moduleRef.get(PaymentsService).refunds.size).toBe(1);
    expect(moduleRef.get(InventoryService).stock.get('kibble')).toBe(10);
  });

  it('cancels with compensation', async () => {
    const o = order('a3');
    await client.start('order-fulfilment', o, { id: fulfilmentId(o.id) });
    await worker.drain();

    expect(await client.cancel(fulfilmentId(o.id), 'Customer changed their mind')).toMatchObject({ accepted: true });
    await worker.drain();

    expect(await client.getStatus(fulfilmentId(o.id))).toMatchObject({ status: 'cancelled' });
  });

  it('starts idempotently, and fails fast on non-retryable errors', async () => {
    const o = order('a4');
    expect(await client.start('order-fulfilment', o, { id: fulfilmentId(o.id) })).toMatchObject({ created: true });
    expect(await client.start('order-fulfilment', o, { id: fulfilmentId(o.id) })).toMatchObject({ created: false });
    await expect(client.start('order-fulfilment', order('a4', 2), { id: fulfilmentId(o.id) })).rejects.toThrow();

    await client.start('order-fulfilment', { ...order('a5'), total: 200_000 }, { id: fulfilmentId('a5') }); // declined card
    await client.start('order-fulfilment', order('a6', 11), { id: fulfilmentId('a6') }); // out of stock
    await worker.drain();

    expect((await client.getStatus(fulfilmentId('a5')))!.error?.message).toContain('declined');
    expect((await client.getStatus(fulfilmentId('a6')))!.error?.message).toContain('Not enough stock');
  });
});

Results of the test above: after the first drain() the instance is suspended with custom status awaiting-delivery, waiting for shipment.delivered and shipment.returned, with the journal showing charge-payment and reserve-stock completed and await-delivery pending; after the signal it is suspended again in status delivered, waiting for its seven-day timer; after advance('7d') it completes with the output { chargeId, trackingNumber } and exactly one review request sent. With no delivery, advance('3d') ends the instance as failed with the message Order a2 was not delivered within 3 days., the compensations in the journal are $compensate:reserve-stock then $compensate:charge-payment, the refund is recorded once and the stock is back to 10. cancel() ends as cancelled. A repeated start() with the same id and input returns created: false, and a declined card (NonRetryableStepError) fails after one attempt.

What the examples are tested against

All code compiles with tsc under strict mode in a project generated by the Nest 12 CLI, and is tested with Vitest and Test.createTestingModule. What is exercised for real: the packages' own code (relay, claims, leases, retries, dead letters, inboxes, migrations, worker, journals, signals, timers, compensation, versions, child workflows, the CQRS bridge), the SQL of the PostgreSQL stores on PGlite, Drizzle transactions and rollbacks, and a Nest TCP microservice receiving the outbox envelope. What is not exercised: a PostgreSQL or MySQL server (so no multi-instance races, lease takeover between processes, pool sizing or SKIP LOCKED contention: the official page recommends running the store contract suites with concurrent: true against your production database engine), MariaDB or MySQL at all, TypeORM, Prisma and Kysely executors (taken from the official documentation), Redis, Kafka and other brokers as outbox transports, real time (the workflow tests use a manual clock, so real timing and shutdown behavior under load are not covered), and the cron schedule, the codec and the invoice batch snippets, which are shown from the official tutorial and are marked as such. The examples do not use the @nestjs/drizzle package of the official tutorial; they create the Drizzle database directly.

Where the book is outdated

The reference book (see the section bibliography) was published in January 2025 and builds reliable messaging by hand: a hand-written outbox table and polling publisher, retry and dead-letter logic of its own, and sagas assembled from @nestjs/cqrs sagas and queues. Those chapters are still a useful explanation of why the patterns exist, but the framework now ships first-party packages for them, @nestjs/outbox and @nestjs/workflows, which cover leases, fencing, ordering keys, inboxes, retries and durable waits that a hand-rolled version usually gets wrong. A CQRS saga is in-process and forgets in-flight state on a restart; a long-lived, multi-step process with compensation belongs in a workflow. Verify any import path or decorator from the book against the package typings before copying it.

What changed recently

  • @nestjs/outbox (0.1.0 on 2026-10-02) and @nestjs/workflows (0.0.1) are new, pre-1.0 packages. Both ship PostgreSQL and MySQL stores, a CLI for migrations (nest-outbox, nest-workflows) and contract-test suites under /testing. Do not rely on exact version numbers; they move daily.

  • @nestjs/cqrs 12.1 lets an aggregate’s commit() take a dispatcher context, so its events can start and signal workflows inside your transaction, through @nestjs/workflows/cqrs; see CQRS.

  • All NestJS 12 packages are ESM-only, so the examples use .js import extensions; both packages require Node.js 20.19 or later.

  • @nestjs/locks (also pre-1.0) provides @OnOneInstance(), which the official docs use to run a purge job on a single instance; see resilience, idempotency and locks.