Skip to main content

The Problem 💀

Consider a typical e-commerce flow:

The Failure Window

There’s a gap between save() 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 event is silently lost — no trace, no retry, no recovery.

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:
Behind the scenes:
  1. Immediate publishdispatchAll() tries the real bus first (RabbitMQ, Kafka, etc.)
  2. SuccessoutboxStore.markPublished(eventId) — marks as done
  3. FailureoutboxStore.markFailed(eventId, error) — records the error
  4. Background safety netOutboxPublisher polls for pending events and retries
The outbox is the safety net, not the primary path. You get low-latency delivery when the broker is healthy, and guaranteed delivery when it’s not.
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. The id column stores the event’s own eventId — this is how markPublished(eventId) does a direct primary key lookup: Pick your ORM:
Copy this into your schema.prisma:
Then import the schema constant for reference:
Then run your migration as usual (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

When you call 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 an outboxStore on your repository, the save() method automatically:
  1. Extracts uncommitted domain events from the aggregate (using duck-typing — no direct dependency on BaseAggregate)
  2. Clears the events from the aggregate (so dispatchAll() won’t double-publish)
  3. Saves the events to the outbox table in the same transaction context
This means that even if 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 an IDomainEventBus 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

Tune pollIntervalMs to your latency tolerance:
Polling too frequently adds unnecessary database load. Start with 5 seconds and adjust based on your event volume and latency requirements.

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 in failed status:
Set up alerts when these counts exceed a threshold — it means the background publisher isn’t keeping up or the broker is down.

Error Handling

The OutboxEventBusDecorator re-throws publish errors so your use case code can respond:

Exports Summary