Nano Store - Ecommerce Docs
Developer ResourcesProducts

Transactional Outbox

The enqueue rule for every future module — same-transaction outbox_events rows relayed by a dispatcher.

Audience: Backend developers building the next module that enqueues background work Scope: The outbox pattern, its statuses, delivery guarantees, and the two exemptions

Transactional Outbox

1. The rule

Every BullMQ enqueue is an outbox_events row written in the same transaction as the business change; a dispatcher relays it. A direct queue.add() in a request handler is a defect.

A direct enqueue and a database write are two operations that cannot be made atomic. The write commits, the enqueue throws, the request returns 200, and a job that should exist does not — with no error anywhere. The inverse is worse: an enqueue that succeeds before the transaction rolls back dispatches work that was never committed. Writing the outbox_events row inside the business transaction makes both impossible: either the row and the business change commit together, or neither does.

2. How it works

  1. Enqueue — OutboxService.enqueue(tx, ...) inserts a row with status = 'pending' as the last statement of the same transaction that made the business change. Nothing is scheduled directly.
  2. Dispatch — OutboxQueueProcessor, the single worker on the OUTBOX queue, claims pending rows in bounded batches, one transaction per pass, and routes each row's job name to its handler — OutboxJob.DISPATCH to the OutboxDispatcherProcessor handler, which calls queue.add() on the target queue. A row successfully handed to BullMQ is marked status = 'dispatched' with processed_at.
  3. Failure — a row that cannot be routed (no BullMQ queue with that name registered) is parked status = 'dead' with last_error set, rather than retried forever.
  4. Cleanup — a periodic cleanup processor removes dispatched rows after they have served their purpose.

The unique index on the table makes the insert idempotent: re-running an enqueue under the same business key creates one row, not two.

3. Delivery is at-least-once — consumers must be idempotent

The dedupe index makes the insert idempotent. It does nothing about a repeated delivery: the dispatcher can enqueue a job and crash before marking the row dispatched, and the next pass hands it to BullMQ again. Every consumer must be idempotent. The catalog job processors already are — the lease-based claim (WHERE status = 'queued' OR processing past TTL) means a duplicate enqueue is a no-op that exits cleanly rather than double-work. New consumers must follow the same pattern: make the work idempotent by construction, never assume a job runs exactly once.

4. The two exemptions

  1. A cron job with no accompanying database write. If nothing is committed, there is nothing to make atomic with — scheduling straight to the queue is correct.
  2. catalog. Catalog shipped earlier on insert-then-publish plus compensation (a failed enqueue marks the job row failed in the same UPDATE) and is deliberately not retrofitted. It is grandfathered, not precedent: new modules and new enqueues go through the outbox.

5. What a module author must do

  • Add the enqueue inside the transaction that makes the business change: OutboxService.enqueue(tx, ...) — never queue.add() in a handler, never enqueue after commit.
  • Register the queue name in packages/jobs and make the consumer idempotent.
  • If you are tempted to "just call queue.add() for this one thing", that is the exact drift the outbox exists to prevent.

6. Where it lives

  • apps/api/src/modules/outbox/ — the outbox service, the single queue worker (outbox-queue.processor.ts), the dispatch and cleanup handlers, the dispatch scheduler, and two modules (infra + workers).
  • packages/db/src/schema/outbox/outbox-events.ts — the table: status (default pending), processed_at, last_error, the dedupe unique index, and the pending-partial index the dispatcher scans.
  • Queue contract: QueueName.OUTBOX in packages/jobs.