Files
renovate[bot] 9154c4cca3 chore(deps): update dependency oxlint to v1.90.0 (#18291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-27 10:26:12 +01:00
..

Transactional outbox sample

The application built in the Transactional outbox tutorial, with @nestjs/outbox: an online store's order API that saves an order and its messages in one database transaction, and a relay that publishes them after the commit.

  • POST /orders saves the order and its messages in one transaction.
  • Two in-process handlers send the confirmation email and reserve stock.
  • A separate analytics microservice, with a database of its own, receives order events over TCP, in order for each order.
  • Failures are retried, and a small admin API lists, requeues and purges dead letters.

What is where

Path Contents
src/ The order API, on PostgreSQL with Drizzle (@nestjs/drizzle)
src/app.module.ts OutboxModule, and its store, PostgresOutboxStore, on the Drizzle database
src/database/ The Drizzle schema: the order API's own tables
src/orders/ OrdersService adds the messages through the order's transaction
src/notifications/, src/inventory/ The @OnOutboxMessage() handlers
src/outbox-admin/ The dead-letter routes and the outbox's stats
drizzle/ The order API's migrations, written by drizzle-kit: its tables and the products
analytics-service/ The analytics microservice: its schema, migrations, AppModule and TCP consumer
e2e/ The tests

Installation

npm install

Running

This example requires docker or a local PostgreSQL installation, with two databases: store for the order API and analytics for the analytics service.

Docker

There is a docker-compose.yml file for starting Docker, which creates both databases.

docker-compose up

After running the sample, you can stop the Docker container with

docker-compose down

Migrations

Apply each service's migrations, which create its own tables:

$ DATABASE_URL=postgres://postgres:postgres@localhost:5432/store npx drizzle-kit migrate
$ DATABASE_URL=postgres://postgres:postgres@localhost:5432/analytics npx drizzle-kit migrate --config analytics-service/drizzle.config.ts

There are no tables to create for the outbox. Its store, PostgresOutboxStore from @nestjs/outbox/postgres, keeps its messages, dead letters and inbox in a schema of its own, nest_outbox, in each service's database, and creates it when the service starts. In production (NODE_ENV=production) it doesn't: apply its migrations in your deploy step, next to drizzle-kit's, with npx nest-outbox migrate (it reads DATABASE_URL, or --url), or put the SQL of PostgresOutboxStore.migrationSql() (npx nest-outbox sql) in a migration of your own. npx nest-outbox status exits with 1 while the schema is behind.

Run the sample

Start the analytics service (TCP port 4001), then the order API (port 3000), each in its own terminal:

$ DATABASE_URL=postgres://postgres:postgres@localhost:5432/analytics npm run start:analytics
$ DATABASE_URL=postgres://postgres:postgres@localhost:5432/store ADMIN_TOKEN=s3cret npm run start

Each logs the store it registered when it starts, and the store creates its schema on the first start:

[Nest] 52654  - 09/30/2026, 6:45:43 PM     LOG [OutboxModule] OutboxStorage: PostgresOutboxStore
[Nest] 52654  - 09/30/2026, 6:45:43 PM     LOG [OutboxModule] PostgresOutboxStore: migrated schema "nest_outbox" to version 1.
[Nest] 52743  - 09/30/2026, 6:45:59 PM     LOG [OutboxModule] OutboxStorage: PostgresOutboxStore
[Nest] 52743  - 09/30/2026, 6:46:00 PM     LOG [OutboxModule] PostgresOutboxStore: migrated schema "nest_outbox" to version 1.
Variable Used by Meaning
DATABASE_URL Both The service's own database
ADMIN_TOKEN Order API The x-admin-token header that the /admin/outbox routes expect
ANALYTICS_HOST, ANALYTICS_PORT Order API Where the analytics service listens (127.0.0.1, 4001)
OUTBOX_RELAY Order API off starts an instance that adds messages without publishing them
PORT Both The port to listen on (3000 for the order API, 4001 for the analytics service)

Try it

Place an order. The order API logs the two in-process handlers, and the analytics service logs the revenue:

$ curl -X POST localhost:3000/orders \
    -H 'Content-Type: application/json' \
    -d '{"userId":"user-42","items":[{"productId":"salmon-kibble-2kg","quantity":2}]}'

Order two bags of clumping litter, which has one in stock. The order is accepted, the confirmation email goes out, and the reservation is dead-lettered on its first attempt:

$ curl -X POST localhost:3000/orders \
    -H 'Content-Type: application/json' \
    -d '{"userId":"user-7","items":[{"productId":"clumping-litter-10l","quantity":2}]}'
$ curl localhost:3000/admin/outbox/dead-letters -H 'x-admin-token: s3cret'

Restock, then requeue the message by its id. The reservation goes through, and no second email is sent:

$ docker-compose exec postgres psql -U postgres -d store \
    -c "UPDATE products SET in_stock = in_stock + 5 WHERE id = 'clumping-litter-10l'"
$ curl -X POST localhost:3000/admin/outbox/dead-letters/<id>/requeue -H 'x-admin-token: s3cret'

Stop the analytics service, then place an order and cancel it. Both analytics messages stay pending, and are published in order once the service is back:

$ curl -X POST localhost:3000/orders/<order id>/cancel
$ curl localhost:3000/admin/outbox/stats -H 'x-admin-token: s3cret'

Tests

npm run test:e2e

Test Runs on
orders.e2e-spec.ts: the order API, with the relay driven by the test PGlite
analytics.e2e-spec.ts: the analytics service, over TCP PGlite
drizzle-outbox.store.e2e-spec.ts: PostgresOutboxStore through Drizzle, against the store contract suites of @nestjs/outbox/testing PGlite, and PostgreSQL

PGlite is PostgreSQL in the test's process, so those tests need nothing else. The store suites also run on a server, where transactions really overlap, and are skipped without one. Point them at the container:

$ SQL_TEST_PG_URL=postgres://postgres:postgres@localhost:5432/postgres npm run test:e2e

They create databases of their own on that server, and drop them afterwards.