The Problem 💀
Consider a typical e-commerce flow:The Failure Window
There’s a gap betweensave() and dispatchAll(). If the process dies in that gap:
This isn’t theoretical. It happens in production:
- Process crashes (unhandled exceptions, OOM kills)
- Deployments (container is replaced mid-request)
- Network blips (message broker unreachable at that exact moment)
- Infrastructure (node restart, pod eviction)
The Solution — Transactional Outbox Pattern
The Transactional Outbox Pattern ensures events are persisted atomically with your aggregate write, then published by a background process:Immediate-First, Outbox as Safety Net
Rich Domain’s implementation uses a decorator pattern — your code stays identical:- Immediate publish —
dispatchAll()tries the real bus first (RabbitMQ, Kafka, etc.) - Success →
outboxStore.markPublished(eventId)— marks as done - Failure →
outboxStore.markFailed(eventId, error)— records the error - Background safety net —
OutboxPublisherpolls for pending events and retries
The
repo.save() call also auto-saves uncommitted events to the outbox table in the same transaction — so even if dispatchAll() isn’t called at all, your events are preserved.Proven Pattern
This isn’t new. Established frameworks have used this pattern for years:
Frameworks like Dapper (.NET) and Spring Cloud Stream (Java) also support variations of this pattern.
@woltz/rich-domain-outbox brings the same reliability guarantee to the TypeScript/Node.js ecosystem — with zero external dependencies beyond your existing database.
Quick Start
Step 1: Install
Step 2: Create the Outbox Table
The outbox table has a simple, fixed schema. Theid column stores the event’s own eventId — this is how markPublished(eventId) does a direct primary key lookup:
Pick your ORM:
- Prisma
- Drizzle
- TypeORM
- Raw SQL
Copy this into your Then import the schema constant for reference:
schema.prisma:prisma migrate dev, drizzle-kit generate, typeorm migration:run, etc.).
Step 3: Wrap your EventBus
Step 4: Start the OutboxPublisher
Step 5: Your Code Stays the Same
The outbox is the safety net, not a replacement. Events are published immediately when possible. The background publisher only picks up events that failed to publish (or were never dispatched at all).
ORM Integration
Each ORM adapter provides an outbox store that integrates with the adapter’s transaction management.Prisma
repo.save(order) inside a uow.transaction(), the outbox events are written in the same database transaction as the aggregate — guaranteeing atomicity.
Drizzle
TypeORM
How Auto-Save Works
When you configure anoutboxStore on your repository, the save() method automatically:
- Extracts uncommitted domain events from the aggregate (using duck-typing — no direct dependency on
BaseAggregate) - Clears the events from the aggregate (so
dispatchAll()won’t double-publish) - Saves the events to the outbox table in the same transaction context
dispatchAll() is never called, your events are safely stored in the outbox. The OutboxPublisher will pick them up on the next poll cycle.
API Reference
OutboxEventBusDecorator
Wraps anIDomainEventBus to track publish success/failure in the outbox.
OutboxPublisher
Background process that polls the outbox table and publishes pending events.OutboxPublisherConfig
IOutboxStore
The contract that all ORM-specific outbox stores implement.OutboxEntry
Plain immutable class representing an outbox row.OutboxStatus
Best Practices
Polling Interval
TunepollIntervalMs to your latency tolerance:
Batch Size
batchSize controls how many events are fetched per poll cycle:
Graceful Shutdown
Always stop the publisher during application shutdown to avoid in-flight message loss:Monitoring
Monitor the outbox table for events stuck infailed status:
Error Handling
TheOutboxEventBusDecorator re-throws publish errors so your use case code can respond: