Introduction
The Outbox Pattern solves the dual-write problem in event-driven architectures. When a business transaction modifies state and must publish an event, any failure between the two operations creates inconsistency. The pattern stores the event in an outbox table inside the same ACID transaction, then an external process reliably publishes it to the broker. This approach guarantees at-least-once delivery while preserving data consistency. In 2026, with the rise of distributed systems and high-availability requirements, this pattern has become indispensable for teams running critical microservices.
Prerequisites
- Node.js 20+ and TypeScript 5.4+
- NestJS 10+
- Prisma 5.20+
- RabbitMQ or any AMQP-compatible broker
- Solid understanding of transactions and the Saga pattern
Prisma Schema for the Outbox Table
model OutboxEvent {
id String @id @default(uuid())
aggregateType String
aggregateId String
eventType String
payload Json
createdAt DateTime @default(now())
processedAt DateTime?
retryCount Int @default(0)
@@index([processedAt])
@@index([createdAt])
}The outbox table captures every event along with its aggregate, type, and JSON payload. The index on processedAt lets the processor quickly retrieve unprocessed events.
Atomic Persistence Service
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class OutboxService {
constructor(private readonly prisma: PrismaService) {}
async saveEvent<T>(
tx: any,
aggregateType: string,
aggregateId: string,
eventType: string,
payload: T
) {
return tx.outboxEvent.create({
data: {
aggregateType,
aggregateId,
eventType,
payload: payload as any,
},
});
}
}This service accepts a Prisma transaction and inserts the event inside the same transaction as the business operations, guaranteeing atomicity.
Business Transaction with Outbox
async createOrder(dto: CreateOrderDto) {
return this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: dto });
await this.outboxService.saveEvent(
tx,
'Order',
order.id,
'OrderCreated',
{ orderId: order.id, total: dto.total }
);
return order;
});
}Order creation and event insertion happen in a single transaction. If the event insertion fails, the order is rolled back.
Event Processor (Polling)
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AmqpService } from '../amqp/amqp.service';
@Injectable()
export class OutboxProcessor implements OnModuleInit {
private isRunning = false;
constructor(
private prisma: PrismaService,
private amqp: AmqpService
) {}
onModuleInit() {
this.startPolling();
}
private async startPolling() {
this.isRunning = true;
while (this.isRunning) {
const events = await this.prisma.outboxEvent.findMany({
where: { processedAt: null },
orderBy: { createdAt: 'asc' },
take: 50,
});
for (const event of events) {
await this.publishWithRetry(event);
}
await new Promise(r => setTimeout(r, 500));
}
}
private async publishWithRetry(event: any) {
try {
await this.amqp.publish('orders', event.eventType, event.payload);
await this.prisma.outboxEvent.update({
where: { id: event.id },
data: { processedAt: new Date() },
});
} catch (e) {
await this.prisma.outboxEvent.update({
where: { id: event.id },
data: { retryCount: { increment: 1 } },
});
}
}
}The processor periodically polls for unprocessed events and publishes them. Failures increment the retry counter for later handling.
NestJS Module Configuration
import { Module } from '@nestjs/common';
import { OutboxService } from './outbox.service';
import { OutboxProcessor } from './outbox.processor';
@Module({
providers: [OutboxService, OutboxProcessor],
exports: [OutboxService],
})
export class OutboxModule {}The module exposes the service and automatically starts the processor when the application boots.
Best Practices
- Keep payload size under 10 KB and store large objects in object storage.
- Implement a dead-letter queue after 5 retry attempts.
- Use composite indexes on (processedAt, createdAt) for high volumes.
- Add a correlation field for distributed tracing.
- Always test network partition scenarios between the database and broker.
Common Mistakes to Avoid
- Forgetting to pass the transaction to the outbox service, breaking atomicity.
- Not handling duplicate events on the consumer side (idempotency).
- Running a single processor without locking in multi-instance deployments.
- Storing sensitive data in plain text inside the JSON payload.
Going Further
Deepen your implementation with Change Data Capture (CDC) and Debezium in our advanced event-driven architecture course.