Happy House - 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. EnqueueOutboxService.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. DispatchOutboxQueueProcessor, 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.