When you’re building a system where services talk to each other, you hit a specific kind of problem pretty quickly. You need to save data to your database and send a message to another service at the same time. Create an order, notify the warehouse. Process a payment, update the customer account. Update a record, trigger a downstream workflow.
The problem isn’t doing both. The problem is doing both reliably.
The Two-Write Problem
Here’s what goes wrong. Your service tries to:
- Save the order to the database ✓
- Publish a message to RabbitMQ — network hiccup, broker is down ✗
The order is saved. The warehouse never finds out. You now have inconsistent state across two systems, and tracking it down is miserable.
Or it goes the other way: the message publishes, but then your database commit fails. Now the warehouse is preparing an order that doesn’t exist yet.
This is the double-write problem. Two separate writes, two separate failure modes. You can’t wrap them in a single transaction because the database and the message broker are different systems.
The Outbox Pattern is the fix.
What the Outbox Pattern Does
Instead of writing to your database AND publishing to your message broker as two separate operations, you do this:
- Write your business data to your database
- In the same database transaction, write a row to a special
outboxtable recording the event you want to publish - Commit once — both writes succeed together or fail together
- A separate background process (the “relay”) reads pending rows from the outbox table and publishes them to the broker
The core insight: a database transaction is atomic. Either both writes commit or neither does. You’re never in a state where the business data is saved but the event was lost.
The relay runs continuously and handles retries. If the broker is temporarily down, events sit in the outbox table and wait. When it recovers, they go out.
What the Outbox Table Looks Like
A typical outbox table is simple:
| Column | Purpose |
|---|---|
id |
Unique identifier |
aggregate_type |
What kind of thing this is about (e.g., “order”) |
aggregate_id |
The ID of that thing |
event_type |
What happened (e.g., “order.confirmed”) |
payload |
The event data as JSON |
status |
pending or sent |
created_at |
When the event was written |
Code Examples
Node.js (PostgreSQL)
// Step 1: Write order + outbox event in one transaction
async function placeOrder(order) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Business data
const result = await client.query(
`INSERT INTO orders (customer_id, total_cents, status)
VALUES ($1, $2, 'confirmed') RETURNING id`,
[order.customerId, order.totalCents]
);
const orderId = result.rows[0].id;
// Outbox event in the SAME transaction
await client.query(
`INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload, status)
VALUES ('order', $1, 'order.confirmed', $2, 'pending')`,
[orderId, JSON.stringify({ orderId, customerId: order.customerId })]
);
await client.query('COMMIT');
return orderId;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Step 2: Relay worker — reads and publishes pending events
async function relayWorker(channel) {
const client = await pool.connect();
try {
const { rows } = await client.query(
`SELECT * FROM outbox WHERE status = 'pending'
ORDER BY created_at LIMIT 100`
);
for (const row of rows) {
await channel.sendToQueue(row.event_type, Buffer.from(row.payload));
await client.query(
`UPDATE outbox SET status = 'sent', sent_at = NOW() WHERE id = $1`,
[row.id]
);
}
} finally {
client.release();
}
}
// Run the relay every 5 seconds
setInterval(() => relayWorker(rabbitChannel), 5000);
.NET C# (Entity Framework Core)
// Outbox message entity
public class OutboxMessage
{
public Guid Id { get; set; } = Guid.NewGuid();
public string AggregateType { get; set; }
public string AggregateId { get; set; }
public string EventType { get; set; }
public string Payload { get; set; }
public string Status { get; set; } = "Pending";
public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow;
public DateTime? SentAtUtc { get; set; }
}
// Service: write order + outbox row in one transaction
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task<Guid> PlaceOrderAsync(PlaceOrderRequest request)
{
await using var transaction = await _db.Database.BeginTransactionAsync();
var order = new Order
{
CustomerId = request.CustomerId,
TotalCents = request.TotalCents,
Status = "Confirmed"
};
_db.Orders.Add(order);
// Outbox event — same SaveChanges, same transaction
_db.OutboxMessages.Add(new OutboxMessage
{
AggregateType = "Order",
AggregateId = order.Id.ToString(),
EventType = "order.confirmed",
Payload = JsonSerializer.Serialize(new { order.Id, request.CustomerId })
});
await _db.SaveChangesAsync();
await transaction.CommitAsync();
return order.Id;
}
}
// Background relay (IHostedService)
public class OutboxRelayWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMessageBus _bus;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await ProcessPendingMessagesAsync();
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
private async Task ProcessPendingMessagesAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pending = await db.OutboxMessages
.Where(m => m.Status == "Pending")
.OrderBy(m => m.OccurredAtUtc)
.Take(100)
.ToListAsync();
foreach (var message in pending)
{
await _bus.PublishAsync(message.EventType, message.Payload);
message.Status = "Sent";
message.SentAtUtc = DateTime.UtcNow;
}
await db.SaveChangesAsync();
}
}
You Don’t Have to Build This Yourself
Several libraries implement the outbox pattern so you don’t need to wire up the relay from scratch:
- Debezium — watches your database’s change log (change data capture) and publishes outbox rows to Kafka or RabbitMQ automatically. No polling loop needed.
- MassTransit (.NET) — built-in outbox support for Entity Framework Core. One configuration line.
- Wolverine (.NET) — another strong option with native outbox and inbox support.
- pg-transactional-outbox (Node.js) — a purpose-built PostgreSQL outbox library.
If you’re starting a new service, use one of these instead of rolling your own.
One Thing to Watch Out For
Because the relay retries failed messages, consumers can receive the same event more than once. Your downstream services need to handle duplicates — either by ignoring events they’ve already processed, or by using an inbox pattern on the receiving side to track what’s been handled. At-least-once delivery is deliberate. Losing events is far worse than handling them twice.
When to Use It
Use the Outbox Pattern when your service publishes events to a message broker, handles anything where a lost message has real consequences — orders, payments, inventory changes — or needs a reliable audit trail. You don’t need it for simple internal CRUD operations or single-service applications. But once you’re running microservices that communicate through events, it’s close to essential.
Further Reading
These are the authoritative resources on this pattern:
- microservices.io — Transactional Outbox — Chris Richardson’s definitive reference. Start here.
- Confluent — The Transactional Outbox Pattern — Deep dive from the Kafka team, with Debezium integration examples.
- AWS Prescriptive Guidance — AWS’s recommended implementation approach.
- MassTransit Outbox Docs — The .NET implementation guide if you’re using MassTransit.

