Outbox and inbox
A service that saves data and then publishes an event writes to two systems that share no transaction. This is the dual-write problem. If the process dies after the save and before the publish, the order exists and nobody hears of it: Inventory never reserves the stock. If the publish comes first and the save fails, other services react to an order that does not exist. The transactional outbox removes the gap by writing the event into the same database, in the same transaction.
The outbox
The handler inserts the order and a row in an Outbox table together. A separate process, the relay, reads unsent rows, publishes them to the broker and marks them sent. As microservices.io puts it, the messages are sent if and only if the transaction commits.
public async Task PlaceOrderAsync(PlaceOrder cmd, CancellationToken ct){ var order = Order.Place(cmd.CustomerId, cmd.Lines); db.Orders.Add(order); db.Outbox.Add(new OutboxMessage { Id = Guid.NewGuid(), Type = nameof(OrderPlaced), Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, order.CustomerId, order.Total)), OccurredOn = DateTimeOffset.UtcNow, }); await db.SaveChangesAsync(ct); // one transaction: the order and its event, or neither}The event is an ordinary row (lines 6 to 12), and one SaveChangesAsync commits both rows or neither. The request now returns as soon as the database commits. The event leaves a moment later, which is one more source of eventual consistencyEventual consistencyAfter a change, other services catch up later, and the design has to allow for the gap.Opens in a new tab.
The relay
The relay here is a BackgroundService that polls once a second. IMessagePublisher stands for a thin wrapper over the broker client. Another way to read the outbox is to follow the database’s transaction log, which microservices.io lists as the alternative to polling.
public sealed class OutboxRelay(IServiceScopeFactory scopes, IMessagePublisher publisher) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); while (await timer.WaitForNextTickAsync(ct)) { using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>(); var pending = await db.Outbox .Where(m => m.SentAt == null) .OrderBy(m => m.OccurredOn) .Take(50) .ToListAsync(ct); foreach (var message in pending) { await publisher.PublishAsync(message.Type, message.Payload, message.Id, ct); message.SentAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(ct); // a crash before this line means a second publish } } }}It reads rows oldest first, so events leave in the order they were written. Lines 19 to 21 are where it can go wrong: a crash after the publish and before the row is marked sent publishes the event again on restart. The outbox therefore gives at-least-once delivery (see delivery guaranteesDelivery guaranteesAt-most-once, at-least-once and exactly-once: what a broker promises, and what it cannot.Opens in a new tab). The row’s Id is passed along as the message ID, so every copy carries the same one. With several relay instances, rows need claiming or locking so two do not publish the same row. Sent rows should be deleted or archived after a while.
The inbox
The consumer’s mirror of the outbox is the inbox: a table of message IDs it has handled, written in the same transaction as its own change. The inbox collapses the repeats the outbox may produce, and together the two give effectively-once processing. The code is in idempotent consumersIdempotent consumersA consumer that gives the same result whether a message arrives once or five times.Opens in a new tab.
In a library
Writing this by hand is mostly done once. MassTransit has an Entity Framework outbox, enabled with AddEntityFrameworkOutbox and UseBusOutbox, with tables OutboxMessage, OutboxState and InboxState. Its delivery service polls the outbox, OutboxState handles locking across service instances, and InboxState tracks received messages by MessageId. NServiceBus has an outbox as well. See messaging frameworksMessaging frameworks for .NETWhat MassTransit, NServiceBus, Wolverine and CAP add on top of a raw broker client.Opens in a new tab.
Sources
- Pattern: Transactional outbox, microservices.io · accessed June 12, 2026
- Implement the Transactional Outbox pattern by using Azure Cosmos DB, Azure Architecture Center · accessed June 12, 2026
- Idempotent Consumer pattern, Azure Architecture Center · accessed June 12, 2026
- Transactional outbox, MassTransit documentation · accessed June 12, 2026