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
- Enqueue —
OutboxService.enqueue(tx, ...)inserts a row withstatus = 'pending'as the last statement of the same transaction that made the business change. Nothing is scheduled directly. - Dispatch —
OutboxQueueProcessor, the single worker on theOUTBOXqueue, claims pending rows in bounded batches, one transaction per pass, and routes each row's job name to its handler —OutboxJob.DISPATCHto theOutboxDispatcherProcessorhandler, which callsqueue.add()on the target queue. A row successfully handed to BullMQ is markedstatus = 'dispatched'withprocessed_at. - Failure — a row that cannot be routed (no BullMQ queue with that name registered) is parked
status = 'dead'withlast_errorset, rather than retried forever. - 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
- 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.
catalog. Catalog shipped earlier on insert-then-publish plus compensation (a failed enqueue marks the job rowfailedin the sameUPDATE) 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, ...)— neverqueue.add()in a handler, never enqueue after commit. - Register the queue name in
packages/jobsand 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(defaultpending),processed_at,last_error, the dedupe unique index, and the pending-partial index the dispatcher scans.- Queue contract:
QueueName.OUTBOXinpackages/jobs.