Transactional Outbox and Inbox: Understanding and Designing Around the Dual Write Problem
Committing to the database and separately publishing to the broker opens two failure windows; the fix is to write the message to an outbox table in the same transaction as the business data, publish it with a separate relay, and drop the relay's duplicates with an inbox on the consumer side. Dual write scenarios, polling vs CDC relays, losing messages with a watermark on PostgreSQL, and multiple relays with SKIP LOCKED, all with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10; the examples use Entity Framework Core (EF Core) 10 with SQLite, and one experiment uses PostgreSQL 17.
Guess First
Bookstore's checkout saves the order and then sends the OrderPlaced integration event to the broker so that Inventory can reserve stock. The code is written in the order that first comes to mind:
namespace Bookstore.Ordering.Naive;
public sealed class PlaceOrderHandler(OrderingDbContext db, IMessagePublisher publisher, TimeProvider clock)
{
public async Task<OrderId> HandleAsync(PlaceOrder command, CancellationToken ct)
{
Order order = Order.Place(command.CheckoutId, command.CustomerId, command.Lines, command.Currency);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
MessageEnvelope envelope = MessageEnvelope.Create(
OrderPlacedV1.From(order), OrderPlacedV1.TypeName, command.CheckoutId, clock);
await publisher.PublishAsync(envelope, ct);
return order.Id;
}
}
Two questions. If SaveChangesAsync succeeds and then the broker is unreachable, or the process is shut down by a deploy at exactly that moment, how does Inventory ever learn about this order? And if someone on the team says "then let's publish first", which new failure walks in?
Dual Write: Two Writes, Two Failure Windows
The handler actually writes to two separate systems: the database and the broker. The literature calls this a dual write. No mechanism turns the two into one atomic operation, so there is always a window between them. To see the window, I ran the handler with the broker down, then reversed the order and submitted the same checkout twice (the user double-clicking):
public sealed class PublishFirstHandler(OrderingDbContext db, IMessagePublisher publisher, TimeProvider clock)
{
public async Task<OrderId> HandleAsync(PlaceOrder command, CancellationToken ct)
{
Order order = Order.Place(command.CheckoutId, command.CustomerId, command.Lines, command.Currency);
MessageEnvelope envelope = MessageEnvelope.Create(
OrderPlacedV1.From(order), OrderPlacedV1.TypeName, command.CheckoutId, clock);
await publisher.PublishAsync(envelope, ct); // publish first
db.Orders.Add(order);
await db.SaveChangesAsync(ct); // then commit
return order.Id;
}
}
1) Commit, then publish; broker is down
handler failed: Broker unavailable
orders in db: 1, messages on broker: 0
2) Publish, then commit; the same checkout is submitted twice
click 2: SQLite Error 19: 'UNIQUE constraint failed: Orders.CheckoutId'.
orders in db: 1, messages on broker: 2
The first scenario is the commit-before-publish window: the order is in the database and the event is nowhere. The user sees an error page, yet the order exists; no stock is reserved, no confirmation email goes out, and nobody notices, because apart from an exception in the log there is no trace. Retrying does not help: running the handler again creates a new order, and retrying only the publish requires the process to still be alive. If the crash lands in this window, nobody is left to retry.
The second scenario is the publish-before-commit window, and it is sneakier: the unique index on CheckoutId rightly rejected the second order, but the event had already left. The broker holds two OrderPlaced messages, the database one order. Inventory will reserve stock twice, and the second event's OrderId points to an order that never existed. The same window opens for anything that fails the commit: a validation error, a deadlock, a concurrency conflict. Swapping the order does not fix the problem; it only changes which side lies: in the first case an event is missing, in the second there is an extra one.
Why Not Two-Phase Commit?
The classic answer to writing atomically to two resources is 2PC (Two-Phase Commit): a coordinator first asks every participant "are you ready?", and if all say yes it tells them to commit. In .NET Framework this worked almost invisibly through TransactionScope and MSDTC (Microsoft Distributed Transaction Coordinator). Today there are three obstacles in practice. First, the participants must support a protocol such as XA (the X/Open distributed transaction standard); RabbitMQ's channel transactions and Kafka's transactions are only valid inside each broker, and Azure Service Bus, according to its transactions page on Microsoft Learn, only offers transactions across its own entities and states plainly that databases called by message-processing code are not enlisted automatically. Second, distributed transaction support came back to .NET in .NET 7, but only on Windows and only when explicitly allowed through TransactionManager.ImplicitDistributedTransactions; for a service running in Linux containers that door is closed. Third, and most important, 2PC multiplies availability: if the coordinator or one participant stops responding, the others wait while holding their locks. We will look at why in detail in the "Distributed Transactions and Two-Phase Commit" post of the upcoming Distributed Systems Fundamentals series. The conclusion here is enough: if we cannot write to two systems at once, we must write to one.
Outbox: Writing the Message in the Same Transaction as the Business Data
The pattern Chris Richardson describes on microservices.io as the Transactional Outbox does exactly that: the outgoing message is not written to the broker but to a table in the database that holds the business data, inside the same transaction. A separate process, the message relay, reads that table and carries the rows to the broker. The database is now the single source of truth: if the order exists, so does the message; if it does not, neither does the message.

public sealed class OutboxMessage
{
private OutboxMessage() { } // EF Core
public long Sequence { get; private set; } // write order (identity)
public Guid MessageId { get; private set; } // the consumer's dedup key
public string Type { get; private set; } = "";
public Guid CorrelationId { get; private set; }
public DateTimeOffset OccurredAt { get; private set; }
public string Payload { get; private set; } = "";
public DateTimeOffset? ProcessedAt { get; private set; }
public int Attempts { get; private set; }
public static OutboxMessage From(MessageEnvelope e) => new()
{
MessageId = e.MessageId, Type = e.Type, CorrelationId = e.CorrelationId, OccurredAt = e.OccurredAt, Payload = e.Payload
};
public MessageEnvelope ToEnvelope() => new(MessageId, Type, CorrelationId, OccurredAt, Payload);
public void MarkProcessed(DateTimeOffset at) => ProcessedAt = at;
public void RecordFailure() => Attempts++;
}
public sealed class PlaceOrderHandler(OrderingDbContext db, TimeProvider clock)
{
public async Task<OrderId> HandleAsync(PlaceOrder command, CancellationToken ct)
{
Order order = Order.Place(command.CheckoutId, command.CustomerId, command.Lines, command.Currency);
MessageEnvelope envelope = MessageEnvelope.Create(
OrderPlacedV1.From(order), OrderPlacedV1.TypeName, command.CheckoutId, clock);
db.Orders.Add(order);
db.Outbox.Add(OutboxMessage.From(envelope));
await db.SaveChangesAsync(ct); // order and message: one transaction, both or neither
return order.Id;
}
}
The row is the serialized form of the envelope we described in the series' "Message Types" post (I left out the causation id to keep the example short): MessageId will be the consumer's dedup key, Type is the versioned contract name (bookstore.ordering.order-placed.v1), CorrelationId comes from the checkout, and Payload is JSON (JavaScript Object Notation). The outbox table is an ordinary table; EF Core inserts it in the same SQL (Structured Query Language) transaction as the order. The handler no longer even knows the broker; the IMessagePublisher dependency is gone. I ran the same two scenarios with the outbox handler while the broker was down, then turned the relay once more after the broker came back:
3) Outbox: same two scenarios
click 2: SQLite Error 19: 'UNIQUE constraint failed: Orders.CheckoutId'.
relay while broker is down: published 0
relay after broker is back: published 1
orders in db: 1, outbox rows: 1, attempts: 1, messages on broker: 1
Checkout succeeds even with the broker down: the order and the outbox row were written together and the user saw no error. When the second click was rejected, its outbox row was not written either; there is no phantom event. The relay recorded one failed attempt in its first round and published the message once the broker returned. Both windows are closed, because we now write to one place in one transaction. The cost is visible too: the event does not leave right after the commit but on the relay's next round. That delay is eventual consistency the size of the polling interval.
The Relay: Polling Publisher and Transaction Log Tailing
There are two ways to move rows from the outbox to the broker. The first is the polling publisher: a loop that reads unprocessed rows at intervals. Its simplest form:
// Polling publisher: publishes committed, not yet published rows in order
public sealed class OutboxRelay(Func<OrderingDbContext> createDb, IMessagePublisher publisher, TimeProvider clock)
{
public async Task<int> PublishPendingAsync(int batchSize, CancellationToken ct)
{
await using OrderingDbContext db = createDb();
List<OutboxMessage> batch = await db.Outbox
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.Sequence)
.Take(batchSize)
.ToListAsync(ct);
foreach (OutboxMessage message in batch)
{
try
{
await publisher.PublishAsync(message.ToEnvelope(), ct); // 1) the broker confirmed
}
catch (BrokerUnavailableException)
{
message.RecordFailure();
await db.SaveChangesAsync(ct);
return 0; // keep the order: skip the rest, start over on the next round
}
message.MarkProcessed(clock.GetUtcNow());
await db.SaveChangesAsync(ct); // 2) marked; a crash between 1 and 2 publishes it again
}
return batch.Count;
}
}
In production this method runs inside a BackgroundService with a PeriodicTimer, the publish waits for the broker's confirmation (publisher confirm, ack), and failures get a backoff. The full version lives in the "Transactional Outbox in .NET" post of the Backend › Working with Message Queues series; here we focus on design decisions. The second way is transaction log tailing, also known as CDC (Change Data Capture): instead of querying the table, the relay reads the database's own transaction log (the WAL (Write-Ahead Log) in PostgreSQL, the CDC tables in SQL Server). The well-known tool for this is Debezium; the Outbox Event Router SMT (Single Message Transform) in version 3.7 expects the columns id, aggregatetype, aggregateid, type and payload by default, derives the topic from aggregatetype, and uses aggregateid as the Kafka message key.
| Polling publisher | Transaction log tailing (CDC) | |
|---|---|---|
| Setup | A loop inside the application | Debezium, Kafka Connect and log access on the database |
| Latency | Up to the polling interval | As soon as the log is written, usually lower |
| Database load | One query and one UPDATE per round | Log reading; no queries against the outbox table |
| Ordering | Depends on the query's ORDER BY | Commit order, as it comes from the log |
| Processed state | On the row (ProcessedAt) | In the relay's log position (offset) |
| Duplicates | On a crash between publish and mark | On a crash before the offset is stored |
At Bookstore's scale (about 800 orders a day, about 120 a minute during campaigns) polling is more than enough. CDC starts to make sense when the query load on the outbox table becomes noticeable, or when Kafka and Debezium are already part of the infrastructure; the operational load it brings to the team usually outweighs the few hundred milliseconds it saves.
The Relay Produces Duplicates: Why the Inbox Is Mandatory
The last row of the table matters: both kinds of relay can produce duplicates. If a polling relay crashes after the broker confirmed but before it writes ProcessedAt, it publishes the same row again when it restarts. I tried this with the relay above; in the first round the process crashes right after the publish:
public sealed class ReserveStockWhenOrderPlaced(InventoryDbContext db, TimeProvider clock)
{
private const string ConsumerName = "inventory.reserve-stock";
public async Task<bool> HandleAsync(MessageEnvelope envelope, CancellationToken ct)
{
OrderPlacedV1 placed = JsonSerializer.Deserialize<OrderPlacedV1>(envelope.Payload)!;
foreach (OrderedBookV1 book in placed.Books)
{
StockItem stock = await db.StockItems.SingleAsync(s => s.BookId == book.BookId, ct);
stock.Reserved += book.Quantity;
}
// Business change and inbox record in the same SaveChanges: both or neither
db.Inbox.Add(new InboxMessage(envelope.MessageId, ConsumerName, clock.GetUtcNow()));
try
{
await db.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateException ex) when (ex.InnerException is SqliteException { SqliteErrorCode: 19 })
{
db.ChangeTracker.Clear(); // duplicate: nothing was written
return false;
}
}
}
4) Relay crashes between publish and mark; consumer has an inbox
relay #1: Process crashed (simulated)
relay #2 (restart): published 1
on broker: 2 messages, distinct ids: 1
consumer: bookstore.ordering.order-placed.v1 applied
consumer: bookstore.ordering.order-placed.v1 duplicate, skipped
Dune: on hand 5, reserved 2
There are two messages on the broker, but their MessageId is the same, because the id was written into the outbox row at commit time and the relay reuses it every time. That is the most valuable thing the outbox gives the consumer: a stable dedup key. The inbox table on the Inventory side has a primary key on (MessageId, Consumer); because the stock change and the inbox record are in the same transaction, the primary key violation on the second delivery rolls back the whole change and Dune's reserved stock stays at 2 instead of climbing to 4. So the outbox is not a pattern on its own but one half of a pair: "at least once" on the producer side, "effect once" on the consumer side. Whether the inbox is a separate table or lives on the entity, external calls that cannot join the transaction, and how long to keep dedup records were covered in the previous post of the series, "Idempotent Consumers: What Happens When the Same Message Arrives Twice?"; the full EF Core implementation is in the "Idempotent Consumers and the Inbox" post of the Backend series.
Ordering, Watermarks and Multiple Relays
Someone who wants a faster relay usually brings two ideas. The first is to get rid of the ProcessedAt update by remembering "the last sequence I published" and asking for sequence > last, a high-water mark. It sounds right because the identity column only grows. But the identity value is assigned at INSERT time, the row becomes visible at commit time, and those two orders are not the same. I tried it with two concurrent checkouts on PostgreSQL, which uses MVCC (Multi-Version Concurrency Control):
await using NpgsqlConnection checkout1 = await db.OpenConnectionAsync();
await using NpgsqlTransaction tx1 = await checkout1.BeginTransactionAsync();
long first = await InsertAsync(checkout1, tx1, "OrderPlaced (Ada)"); // not committed yet
await using NpgsqlConnection checkout2 = await db.OpenConnectionAsync();
long second = await InsertAsync(checkout2, null, "OrderPlaced (Alan)"); // committed immediately
Console.WriteLine($" Ada got sequence {first} (not committed), Alan got {second} (committed)");
long lastSeen = 0;
foreach ((long seq, string type) in await ReadAsync(db, $"sequence > {lastSeen}"))
{
Console.WriteLine($" watermark relay publishes #{seq} {type}");
lastSeen = seq;
}
await db.CreateCommand($"UPDATE outbox SET processed_at = now() WHERE sequence = {second}").ExecuteNonQueryAsync();
await tx1.CommitAsync();
Console.WriteLine($" Ada's transaction commits; watermark relay asks for sequence > {lastSeen}: " +
$"{(await ReadAsync(db, $"sequence > {lastSeen}")).Count} rows");
foreach ((long seq, string type) in await ReadAsync(db, "processed_at IS NULL"))
Console.WriteLine($" flag relay finds #{seq} {type}");
5) High-water mark vs processed flag (PostgreSQL)
Ada got sequence 1 (not committed), Alan got 2 (committed)
watermark relay publishes #2 OrderPlaced (Alan)
Ada's transaction commits; watermark relay asks for sequence > 2: 0 rows
flag relay finds #1 OrderPlaced (Ada)
Ada's order got number 1, but its commit came after Alan's. The watermark relay saw 2, moved its mark to 2, and row 1 never matched a query again: Ada's event vanished silently. The relay that asks with a per-row processed_at flag found it. No error, no log; the kind of bug that only shows up under load, with concurrent transactions. The rule: in a polling relay, keep "where was I" on the row, not in a counter. For the same reason a watermark on timestamps such as OccurredAt is even worse; clock values do not give you commit order either.
The second idea is several relay instances. If the application runs in three pods, three relays read the same rows and publish them three times. The inbox already drops the duplicates, but wasted work and ordering chaos remain. The way to "claim" rows is FOR UPDATE SKIP LOCKED in PostgreSQL (the UPDLOCK, READPAST hints in SQL Server):
const string claim = """
SELECT sequence FROM outbox
WHERE processed_at IS NULL
ORDER BY sequence
LIMIT 3
FOR UPDATE SKIP LOCKED
""";
await using NpgsqlConnection relayA = await db.OpenConnectionAsync();
await using NpgsqlTransaction txA = await relayA.BeginTransactionAsync();
List<long> a = await ClaimAsync(relayA, txA, claim);
await using NpgsqlConnection relayB = await db.OpenConnectionAsync();
await using NpgsqlTransaction txB = await relayB.BeginTransactionAsync();
List<long> b = await ClaimAsync(relayB, txB, claim);
Console.WriteLine($" relay A claimed [{string.Join(", ", a)}], relay B claimed [{string.Join(", ", b)}]");
6) Two relay instances with FOR UPDATE SKIP LOCKED
relay A claimed [1, 2, 3], relay B claimed [4, 5, 6]
The work is split, at a price: 3 and 4 are now published in parallel by two different relays. If both belong to the same order (OrderPlaced followed right away by OrderCancelled), they can reach the broker in reverse order. If ordering matters there are two options. One is a single active relay: with a lease or leader election only one instance publishes and the others wait; simple and enough for Bookstore's volume. The other is partitioning by aggregate: each relay only handles its hash(OrderId) % N slice, so one order's messages always leave from the same relay, in order. Whichever you choose, the rule from the series' "Message Ordering and Partitioning" post still applies: the consumer should not rely on ordering anyway and should be able to recognize a stale message through a version field.
Design Decisions: Mapping, Row Contents, Cleanup and Libraries
Where do we turn a domain event into an integration event? In the "Domain Events" post of the DDD (Domain-Driven Design) series we built a DomainEventsInterceptor running inside SaveChangesAsync, and noted that an AfterCommit handler is exposed to a crash between the commit and the email, and that the answer to this is the outbox. That is the natural place: a handler running in the BeforeCommit phase maps the OrderPlaced domain event to the OrderPlacedV1 contract and adds it to the outbox; since the interceptor runs on the same DbContext, the row joins the same transaction as the order. The mapping should be a deliberate step: dumping the whole domain event to JSON and into the outbox turns every rename in the internal model into another team's broken consumer.
What belongs in the outbox row? Everything needed to reproduce the message, that is, the whole envelope: MessageId, Type, CorrelationId, the causation id, OccurredAt, the traceparent from W3C (World Wide Web Consortium) Trace Context if needed, and the payload. The relay should not compute anything; it carries the row as is. We will see why the trace context belongs on the row in the last post of the series, under observability.
Cleanup: if processed rows stay in the table forever, the polling query slows down. The common solution is a filtered index on ProcessedAt plus a scheduled job that deletes processed rows older than a few days. The retention must not be shorter than the period in which you might want to republish a message; if there is no replay need, a few days is enough. With CDC you can even delete the row right after the INSERT, because the relay reads the log, not the table.
Do you write it yourself or use a library? The license status of .NET libraries that ship an outbox, as of November 2026:
- MassTransit: 9.x is commercially licensed (Massient; the NuGet package's license link is massient.com/license), 8.x stays Apache 2.0, and according to Massient's page v8 support ends on 31 December 2026.
- Wolverine: open source (MIT license); offers outbox/inbox with EF Core and Marten.
- NServiceBus: RPL (Reciprocal Public License) 1.5 or a commercial license; if you will not open your own code under the RPL, you need the commercial license.
- DotNetCore.CAP: open source (MIT license); an event bus built around the outbox.
The table and the relay add up to a few hundred lines of code; a library's real value is that it brings retry, backoff, cleanup, the inbox and observability together. The details of that choice and the MassTransit/Wolverine outbox configuration belong to the Backend series.
Common Pitfalls
Writing the outbox in another transaction
Writing the outbox row through a separate DbContext or connection, "since it's the same database anyway", silently cancels the pattern. Two separate commits are still two separate windows. The row must be written in the same SaveChangesAsync as the business data, or in the same explicitly shared transaction.
Publishing right after the commit as a "fast path"
To cut latency, a common optimization is for the handler to also publish the message directly after the commit while the relay publishes the same row later. The inbox drops the duplicate, but code that forgets to mark the row after a successful direct publish sends every message twice. If you do it, mark the row in the same code path once the publish succeeds and leave the relay only for the ones that slipped through.
Watermarks on counters or clocks
The PostgreSQL experiment above showed it: asking for "everything greater than the last one" by identity or timestamp loses messages when commit order differs from insert order. The bug only appears under concurrent load and is almost never visible in a test environment.
A relay stuck on a poison row
A row that cannot be serialized, or that the broker keeps rejecting, blocks a relay that waits in order to keep ordering, forever. The Attempts counter needs a threshold; a row that exceeds it should move to a separate "failed" state and raise an alert while the rest continues. When ordering matters, holding back only that aggregate's rows is better than stopping the whole table.
Not monitoring the relay
The outbox hides a broker outage from the user; that is a feature, but if you do not monitor it, it hides the outage from you too. The age of the oldest unprocessed row (outbox lag) should be an alerting metric.
When Not to Use It
Where a lost message is corrected by the next operation anyway, the outbox is unnecessary weight: a hint event that invalidates a short-lived cache, or a status notification re-sent every few seconds. Systems where the event itself is the source of truth have a different path: with event sourcing you write to the event store or log first, and everyone, including your own read model, is fed from there; the dual write never arises. That approach is the subject of the upcoming CQRS (Command Query Responsibility Segregation) and Event Sourcing series. Finally, if notifying a module in the same process does not require a broker, a domain event handler in the same transaction is simpler than an outbox.
A Small Experiment
★☆☆ Add a PeriodicTimer loop to the example's OutboxRelay and keep the broker down for 3 seconds. Do checkouts keep succeeding, where does Attempts end up, and in what order are the messages published once the broker is back?
★★☆ Apply the PostgreSQL experiment to the relay: turn PublishPendingAsync into a query that claims rows with FOR UPDATE SKIP LOCKED and run two relays at the same time. Can you find a timing where two messages of the same order (OrderPlaced, OrderCancelled) come out of order? What changes when you partition with hash(OrderId) % 2?
★★★ Add a WriteOutboxWhenOrderPlaced handler running in the BeforeCommit phase to the DomainEventsInterceptor from the DDD series. What happens to the order and the outbox row if the handler throws? List the fields that exist on the domain event but should not make it into the contract.
Together, the outbox and the inbox make a single service's promise "I wrote it and I told everyone" reliable. But in Bookstore an order does not end inside one service: stock is reserved, payment is captured, a shipment is created, and if one of them fails the earlier steps must be undone. In the next post we look at this multi-step workflow, the saga pattern, and the choice between choreography and orchestration.