EnterpriseNET
The Outbox Pattern in .NET — From Fundamentals to Production
By Darshil Shah · Nov 2026 · 12 min read
- .NET
- Outbox Pattern
- Azure Service Bus
- EF Core
- Event-Driven
- SQL Server
A customer clicks Place order. Your Order Service saves the order, then tells Inventory, Billing and Notifications about it. That second step is where many event-driven systems quietly lose data.
This post explains why and how the Outbox Pattern fixes it, using one example and a small .NET proof of concept you can run yourself.
## The problem: one click, two systems
Most of us start with something like this:
```csharp
db.Orders.Add(order);
await db.SaveChangesAsync(); // write #1: SQL Server
await sender.SendMessageAsync(message); // write #2: Azure Service Bus
```
You are writing to two independent systems with nothing tying them together. This is the dual-write problem and it fails in three ways:
1. **The commit succeeds, the publish fails.** You get an exception, but nothing retries the send, so downstream services never hear about the order. Worse, the customer sees an error for an order that was saved and may click again and place it twice.
2. **You publish first, then the commit fails.** Other services act on a ghost order that does not exist.
3. **The process dies between the two lines.** A deployment or an OOM kill means no catch, no retry and no log line. This is the silent failure.
"Just wrap it in a transaction" does not help. SQL Server can roll back `Orders`, but it cannot un-send a message Service Bus already accepted. Azure Service Bus will not join a distributed transaction with SQL Server either and most cloud systems avoid two-phase commit.
## So what is the Outbox Pattern?
Do not write to two systems. Write to one and deliver later.
The Order Service saves the event into an `OutboxMessages` table in the same database, in the same transaction as the order. A background publisher (a plain .NET `BackgroundService`) later reads pending rows, sends them to Azure Service Bus and marks them done. In a real system, Inventory, Billing and Notifications would all react to `OrderCreated`. To keep the example simple, we will follow Inventory from here on.
In the PoC, Inventory reads from a single Service Bus queue. With several consumers, you would publish to a topic and give each service its own subscription. The outbox side does not change at all.
```text
Client
| POST /orders
v
+-----------------------------------+
| Order API |
| BEGIN TRANSACTION |
| INSERT INTO Orders |
| INSERT INTO OutboxMessages |
| COMMIT |
+-----------------------------------+
|
[ SQL Server ]
^
| 1. claim pending rows
| 3. mark processed / schedule retry
+-----------------------------------+
| Outbox Publisher (Worker) |
+-----------------------------------+
| 2. send (MessageId = outbox Id)
v
[ Azure Service Bus queue ]
|
v
+-----------------------------------+
| Inventory Consumer (idempotent) |
+-----------------------------------+
```
Be clear about what you get:
- **Durable storage.** Once the order commits, its event cannot be lost.
- **At-least-once publishing.** The event will reach the broker, occasionally more than once.
- **Idempotent consumers.** These make duplicates harmless, but you build them yourself. The outbox does not.
What you do not get is exactly-once delivery. The outbox does not promise it.
## How it works
1. `POST /orders` arrives. No transaction yet.
2. A transaction inserts the order and the `OrderCreated` event.
3. It commits: both rows or neither. The API returns `201 Created` without touching Service Bus.
4. The publisher claims a small batch of pending rows in one quick statement and puts a lease on them: short compared with holding a transaction open, but longer than it takes to send one batch.
5. It sends each event, using the row's `Id` as the message ID, with no transaction open.
6. On success it sets `ProcessedAtUtc`. On failure it bumps `RetryCount` and schedules a retry with backoff.
Steps 2 and 3 are the whole trick: one ordinary local transaction. Everything after is just retrying.
## Building it in .NET
Snippets below are trimmed from a companion PoC (.NET 10, EF Core, SQL Server, Azure Service Bus). They are for learning, not for pasting into production.
### The outbox table
```csharp
public sealed class OutboxMessage
{
// Essential
public Guid Id { get; set; } // also the Service Bus MessageId
public string EventType { get; set; } = default!;
public string Payload { get; set; } = default!; // JSON
public Guid AggregateId { get; set; } // the OrderId, for correlation
public DateTime CreatedAtUtc { get; set; }
public DateTime? ProcessedAtUtc { get; set; } // null = pending
// Operational
public int RetryCount { get; set; }
public DateTime NextAttemptAtUtc { get; set; }
public string? LastError { get; set; }
public Guid? LockId { get; set; }
public DateTime? LockedUntilUtc { get; set; }
}
```
Only the first six fields are essential. The rest make it survivable in production: retry scheduling, error diagnosis and leases for several workers. Timestamps are UTC `datetime2`. Two indexing choices matter on a table this hot:
```sql
-- Guid stays the message ID; rows are clustered on an ever-increasing key
ALTER TABLE OutboxMessages ADD Sequence bigint IDENTITY(1,1) NOT NULL;
ALTER TABLE OutboxMessages ADD CONSTRAINT PK_OutboxMessages PRIMARY KEY NONCLUSTERED (Id);
CREATE UNIQUE CLUSTERED INDEX IX_OutboxMessages_Sequence ON OutboxMessages (Sequence);
-- Polling only ever touches pending rows
CREATE INDEX IX_OutboxMessages_Pending
ON OutboxMessages (NextAttemptAtUtc, CreatedAtUtc)
WHERE ProcessedAtUtc IS NULL;
```
A random Guid as the clustered key scatters inserts across the whole index and fragments it. `Guid.CreateVersion7()` does not fix that on SQL Server: `uniqueidentifier` sorts by its last bytes first and that is not where v7 puts the timestamp. Cluster on a `bigint IDENTITY` and keep the Guid as the unique message ID. The filtered index stays small because processed rows drop out of it.
### Saving the order and the event together
```csharp
public async Task<Order> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
var now = DateTime.UtcNow;
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = request.CustomerId,
Total = request.Total,
CreatedAtUtc = now
};
var orderCreated = new OrderCreated(order.Id, order.CustomerId, order.Total, now);
await using var tx = await _db.Database.BeginTransactionAsync(ct);
_db.Orders.Add(order);
_db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
EventType = OrderCreated.EventType, // "orders.order-created.v1"
Payload = JsonSerializer.Serialize(orderCreated),
AggregateId = order.Id,
CreatedAtUtc = now
// NextAttemptAtUtc defaults to SYSUTCDATETIME() in the database
});
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); // both rows, or neither
return order;
}
```
There is no Service Bus code here. Strictly, the explicit transaction is not needed: one `SaveChangesAsync` already runs in its own transaction, so this is enough:
```csharp
_db.Orders.Add(order);
_db.OutboxMessages.Add(outboxMessage);
await _db.SaveChangesAsync(ct); // one save, one transaction: both rows or neither
```
You need explicit `BeginTransactionAsync` when the work spans more than one `SaveChangesAsync` or when you mix in Dapper or raw ADO.NET on the same connection. The PoC keeps it to make the boundary visible. With `EnableRetryOnFailure`, an explicit transaction must run inside `CreateExecutionStrategy().ExecuteAsync(…)` or EF Core throws. The single-save version does not need that.
Use a fixed event name, not `nameof(…)`. Renaming a C# type should not break every consumer that routes on it. Putting the version in the name (`.v1`) lets you publish v2 side by side when the payload changes.
### Claiming work without stepping on each other
A plain `SELECT` then `UPDATE` breaks with two workers: both read the same row and both publish it. So the worker claims rows in one atomic statement:
```sql
WITH batch AS (
SELECT TOP (@batchSize) *
FROM OutboxMessages WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE ProcessedAtUtc IS NULL
AND RetryCount < @maxRetries
AND NextAttemptAtUtc <= SYSUTCDATETIME()
AND (LockedUntilUtc IS NULL OR LockedUntilUtc < SYSUTCDATETIME())
ORDER BY CreatedAtUtc
)
UPDATE batch
SET LockId = @lockId,
LockedUntilUtc = DATEADD(SECOND, @leaseSeconds, SYSUTCDATETIME())
OUTPUT inserted.*;
```
`UPDLOCK` stops two workers grabbing the same rows and `READPAST` lets them skip locked rows instead of waiting. The statement commits in milliseconds. The lease (`LockedUntilUtc`) means no transaction stays open during the network call. If a worker dies, its lease expires and another worker takes over.
### Publishing, then marking it done
```csharp
foreach (var message in claimed)
{
using var _ = _logger.BeginScope(new Dictionary<string, object>
{
["OutboxMessageId"] = message.Id,
["OrderId"] = message.AggregateId
});
try
{
await _publisher.PublishAsync(message, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
OutboxMetrics.Failed.Add(1);
await _store.ScheduleRetryAsync(message, lockId, ex, ct);
continue; // only a failed send counts as a retry
}
try
{
// The send already happened; don't let shutdown cancel the bookkeeping.
await _store.MarkProcessedAsync(message.Id, lockId, CancellationToken.None);
_logger.LogInformation("Published {EventType}", message.EventType);
OutboxMetrics.Published.Add(1);
}
catch (Exception ex)
{
// Let the lease expire. The event may be sent again, which consumers handle.
_logger.LogWarning(ex, "Sent but not marked; event may be republished");
}
}
```
A stable `MessageId` lets Service Bus spot a resend:
```csharp
var sbMessage = new ServiceBusMessage(message.Payload)
{
MessageId = message.Id.ToString(),
Subject = message.EventType,
ContentType = "application/json",
CorrelationId = message.AggregateId.ToString()
};
await _sender.SendMessageAsync(sbMessage, ct);
```
The row is marked done only if we still hold the lease:
```csharp
var updated = await _db.OutboxMessages
.Where(m => m.Id == messageId && m.LockId == lockId)
.ExecuteUpdateAsync(s => s
.SetProperty(m => m.ProcessedAtUtc, DateTime.UtcNow)
.SetProperty(m => m.LockId, (Guid?)null)
.SetProperty(m => m.LockedUntilUtc, (DateTime?)null), ct);
if (updated == 0)
_logger.LogWarning("Lease lost; event may be published again");
```
On failure, `ScheduleRetryAsync` bumps `RetryCount`, saves a trimmed `LastError` and backs off exponentially with jitter (2s, 4s, 8s… up to a cap). It computes the next attempt in SQL with `DATEADD(MILLISECOND, @delayMs, SYSUTCDATETIME())`, so every timestamp the claim query compares comes from the database clock, not the app servers. After `MaxRetries`, the row stays in place and is logged as an error, never deleted. Logs carry the order ID and event ID, but never the payload.
## A consumer that shrugs off duplicates
Because publishing is at-least-once, the Inventory consumer records each `MessageId` it handles:
```csharp
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.ProcessedMessages.Add(new ProcessedMessage
{
MessageId = args.Message.MessageId, // primary key
ProcessedAtUtc = DateTime.UtcNow
});
db.StockReservations.Add(new StockReservation { OrderId = evt.OrderId /* ... */ });
try
{
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch (DbUpdateException ex) when (IsUniqueKeyViolation(ex))
{
_logger.LogInformation("Duplicate {MessageId} ignored", args.Message.MessageId);
}
await args.CompleteMessageAsync(args.Message, ct);
```
A duplicate hits the primary key and rolls back, so stock is reserved once. Note the limit: the inbox only protects work committed in the same database transaction. An HTTP call or an email sent alongside it can still happen twice, so those need their own idempotency key.
Two details matter in the PoC. Each message gets its own DI scope and `DbContext`, because a duplicate-key exception leaves entities tracked. And the processor runs with `AutoCompleteMessages = false`, so a message is only completed after this code decides its fate.
This consumer-side table is the Inbox pattern: separate from the outbox, but a natural partner to it. If several consumers share one database, key the inbox on `(ConsumerName, MessageId)`, not `MessageId` alone. Otherwise the first consumer to handle a message makes every other consumer skip it.
## What happens when things break
- **The transaction fails:** both rows roll back and nothing is sent. Nothing to clean up.
- **Service Bus is down:** the order is safe. The event waits in the outbox and goes out once the broker recovers. The API already returned 201, so the customer has no reason to click again.
- **The worker crashes before publishing:** the lease expires and another worker sends the event. Nothing is lost.
- **The worker crashes or the mark fails, after publishing:** the event goes out again once the lease expires. This is the at-least-once window and the outbox alone cannot close it.
- **A duplicate is published:** it carries the same `MessageId`. Service Bus may drop it if duplicate detection is on; otherwise the consumer's inbox skips it.
- **An event keeps failing:** backoff spaces out the attempts. After `MaxRetries` the row stays put, an error is logged and the exhausted count goes up. That count is what your alert should watch. Fix the cause, then re-queue it:
```sql
-- find stuck events
SELECT Id, EventType, AggregateId, RetryCount, LastError
FROM OutboxMessages
WHERE ProcessedAtUtc IS NULL AND RetryCount >= 10;
-- re-queue one after fixing the cause
UPDATE OutboxMessages
SET RetryCount = 0, NextAttemptAtUtc = SYSUTCDATETIME()
WHERE Id = @id;
```
- **The consumer keeps failing:** Service Bus redelivers the message. Once it exceeds the queue's max delivery count (10 by default), the message moves to the dead-letter queue. Monitor that queue, fix the cause, then resubmit. If duplicate detection is on, a resubmit with the same `MessageId` inside the detection window is silently dropped, so wait out the window or resubmit with a new ID. The consumer's inbox still keeps it safe.
An event is always either sent, waiting for a retry or visibly stuck, in the outbox or in the dead-letter queue. It never silently vanishes.
## Before you ship it
- **Idempotent consumers.** Do not skip this. Duplicates come from the publisher and from broker redeliveries. Use an inbox table, a unique key or an upsert.
- **Duplicate detection.** Service Bus drops repeated `MessageId`s within a window (10 minutes by default, up to 7 days). It is not available on Basic tier and can only be enabled when you create the queue. It reduces duplicates; it does not make consumers exactly-once.
- **Retries.** Cap the backoff, add jitter and limit attempts. Keep the SDK's own `ServiceBusRetryOptions` short so the two layers do not multiply.
- **Concurrent workers.** Size the lease from your send time, not your transaction time: longer than one batch takes to send (30 seconds in the PoC for 50 rows), yet short enough that a crashed worker's rows come back quickly.
- **Ordering.** Polling only gives you rough order. The publisher itself can reorder one order's events. If sequence matters, either do not claim a row while an earlier unprocessed row for the same aggregate exists or put a version number on each event and have consumers ignore stale ones. The PoC orders by `CreatedAtUtc` only.
- **Batching and indexes.** Claim around 50 rows at a time and index pending rows. A shorter polling interval means less latency but more database load.
- **Cleanup.** Delete processed rows after a week or so, in small batches. Archive them first if you need an audit trail.
- **Monitoring.** Track pending count, the age of the oldest pending event (your best "relay is stuck" alert), exhausted events, publish failures and the consumer's dead-letter queue count. Log IDs, never payloads or secrets.
- **Tracing.** Store `Activity.Current?.Id` on the outbox row and set it as the message's `Diagnostic-Id` application property, so one trace spans the API, the relay and the consumer.
- **Writing outbox rows automatically.** A `SaveChangesInterceptor` can turn domain events raised on entities into outbox rows, so handlers never forget to add them.
- **Message size.** Service Bus Standard caps messages at 256 KB. Premium allows up to 100 MB over AMQP, but each entity defaults to 1 MB. For larger payloads, store the body in Blob Storage and send a reference (the claim-check pattern).
- **Why a flag, not a high-water mark.** Do not poll "everything after the last ID I sent". Identity values can commit out of order, so a lower ID can appear after you have moved past it. A per-row `ProcessedAtUtc` flag cannot skip rows that way.
## Polling or Change Data Capture?
Our publisher polls the table every few seconds. The alternative, Change Data Capture (CDC), reads the database's transaction log and streams new outbox rows as they commit. Debezium is the best-known option. It has a SQL Server connector and an Outbox Event Router and it usually runs on Kafka Connect.
Either way there is an extra hop to Azure Service Bus. Debezium publishes to Kafka or through Debezium Server to sinks such as Azure Event Hubs, but not to Service Bus. SQL Server 2025's change event streaming also targets Azure Event Hubs only. Delivery is at-least-once in both cases, so consumers stay idempotent.
Polling is simple and needs no extra infrastructure, at the cost of a little latency and steady query load. CDC is near-instant but adds a platform you have to operate. For most business services, polling is plenty. Consider CDC when volume or latency demands it or when you already run Kafka.
## Do you actually need it?
Use the outbox when losing an event would leave other services wrong: orders, payments, account changes, integration events. Skip it when an occasional lost event is fine or when there is no second system.
Before you build your own, look at the libraries. MassTransit and Wolverine ship an EF Core outbox. NServiceBus keeps its outbox in its own persistence (such as SQL Persistence) and your EF Core code joins it by sharing the connection and transaction. Also note that MassTransit v9 is commercially licensed, while v8 remains open source.
## Wrapping up
The outbox turns an impossible problem — an atomic write to a database and a broker — into two easier ones: one local transaction and a relay that retries. Events stop getting lost or invented.
What it does not give you is exactly-once delivery. Stable message IDs and duplicate detection reduce duplicates and idempotent consumers make the rest harmless.
Building reliable event-driven applications needs more than connecting services through a message broker. Engineering teams need dependable data flows, fault-tolerant background processing, idempotent consumers and monitoring that keeps distributed workflows running. We help enterprises build and modernize .NET applications with scalable backend architectures, Azure integrations and cloud-ready practices designed for reliable communication across services.