The Transactional Outbox
Save business data and the messages it produces in one EF Core transaction, relay them to a broker and handle at-least-once delivery.
db.Orders.Add(order);
db.OutboxMessages.Add(OutboxMessage.From(new OrderPlaced(order.Id, order.Total)));
await db.SaveChangesAsync(ct); // one transaction: both or neitherAn order endpoint must save the order and publish OrderPlaced. Doing both directly is a dual write: two systems, no shared transaction.
await db.SaveChangesAsync(ct); // committed
await bus.PublishAsync(orderPlaced, ct); // pod killed here: event never sent
Swap the order and a failed commit leaves a published event for an order that does not exist. Retrying does not fix it, because the crash can land between any two lines. Distributed transactions across a database and a broker are not available in practice.
The pattern
- The request handler adds the order and an
OutboxMessagerow (id, type, JSON payload, occurred time) to the sameDbContext. OneSaveChangesAsynccommits both atomically: either the order and its event exist, or neither does. - A relay, a
BackgroundService, polls for unprocessed rows, publishes each to the broker with publisher confirms, then marks it processed. - A cleanup deletes processed rows after a retention period, typically 7 days.
At-least-once, by design
The relay can crash after the broker confirmed but before the row is marked. On restart it publishes again. The outbox therefore guarantees at least once, never exactly once, and consumers must de-duplicate. Use the outbox row's id as the broker MessageId so a consumer can recognise the repeat.
Running several relays
Every replica runs the relay. To stop two of them publishing the same row, claim a batch inside a transaction with SELECT ... FOR UPDATE SKIP LOCKED (PostgreSQL, MySQL 8; SQL Server uses WITH (UPDLOCK, READPAST, ROWLOCK)). Rows locked by one relay are skipped by the others. The cost: global ordering is gone. If consumers need per-order ordering, partition by aggregate id or run one relay with a lock.
Holding the transaction open while publishing keeps the rows locked for the broker round trip; keep batches small (50 to 100) and give the publish a timeout. The alternative is a lease column: claim rows with UPDATE ... SET locked_until = now() + interval '1 minute', commit, publish, then mark.
The relay begins its own transaction, so with EnableRetryOnFailure on (Aspire's AddNpgsqlDbContext enables it) the batch must run inside db.Database.CreateExecutionStrategy().ExecuteAsync, starting with ChangeTracker.Clear(); otherwise the first query throws InvalidOperationException. The Transactions lesson covers the rule. A retried batch may publish rows a second time, which is the at-least-once delivery above.
Decisions and failure modes
| Concern | Choice |
|---|---|
| Latency | Polling every 1 to 2 seconds is usually enough. Lower it with a wake-up signal (a channel written after commit, or PostgreSQL LISTEN/NOTIFY), keeping the poll as a fallback. |
| Poison rows | Count attempts only for errors specific to a message, back it off with next_attempt_at, and stop after a limit, recording last_error. A broker outage is not the message's fault: stop the batch and pause the relay without counting, or a short outage marks the whole backlog as poison. |
| Table growth | A partial index on unprocessed rows keeps the poll cheap; delete processed rows in batches. |
| Alternative | Change data capture (for example Debezium reading the write-ahead log) removes polling at the cost of new infrastructure. |
Collect domain events in entities and convert them to outbox rows in a SaveChangesInterceptor, so no handler can forget. Teams using an existing bus library may already have an outbox built in; the mechanics are the same.
Example
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// AddNpgsqlDbContext in Aspire turns the same retrying strategy on.
builder.Services.AddDbContext<OrdersDb>(o => o.UseNpgsql(
builder.Configuration.GetConnectionString("Orders"),
npgsql => npgsql.EnableRetryOnFailure()));
builder.Services.AddSingleton<IMessagePublisher, LoggingPublisher>();
builder.Services.AddHostedService<OutboxRelay>();
var app = builder.Build();
app.MapPost("/orders", async (PlaceOrder cmd, OrdersDb db, CancellationToken ct) =>
{
var order = new Order { Id = Guid.CreateVersion7(), CustomerId = cmd.CustomerId, Total = cmd.Total, Status = "Placed" };
db.Orders.Add(order);
db.OutboxMessages.Add(OutboxMessage.From(new OrderPlaced(order.Id, order.CustomerId, order.Total)));
await db.SaveChangesAsync(ct); // order and event commit together
return TypedResults.Created($"/orders/{order.Id}", new { order.Id });
});
app.Run();
public sealed record PlaceOrder(int CustomerId, decimal Total);
public sealed record OrderPlaced(Guid OrderId, int CustomerId, decimal Total);
public sealed class Order
{
public Guid Id { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
public string Status { get; set; } = "";
}
public sealed class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; } = "";
public string Payload { get; set; } = "";
public DateTimeOffset OccurredAt { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
public int Attempts { get; set; }
public DateTimeOffset? NextAttemptAt { get; set; }
public string? LastError { get; set; }
public static OutboxMessage From<T>(T message) => new()
{
Id = Guid.CreateVersion7(),
Type = typeof(T).Name,
Payload = JsonSerializer.Serialize(message, JsonSerializerOptions.Web),
OccurredAt = DateTimeOffset.UtcNow,
};
}
public sealed class OrdersDb(DbContextOptions<OrdersDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>().ToTable("orders");
b.Entity<OutboxMessage>(e =>
{
e.ToTable("outbox_messages");
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.Type).HasColumnName("type").HasMaxLength(200);
e.Property(x => x.Payload).HasColumnName("payload").HasColumnType("jsonb");
e.Property(x => x.OccurredAt).HasColumnName("occurred_at");
e.Property(x => x.ProcessedAt).HasColumnName("processed_at");
e.Property(x => x.Attempts).HasColumnName("attempts");
e.Property(x => x.NextAttemptAt).HasColumnName("next_attempt_at");
e.Property(x => x.LastError).HasColumnName("last_error");
});
}
}
public interface IMessagePublisher
{
Task PublishAsync(string messageId, string type, string payload, CancellationToken ct);
}
public sealed class LoggingPublisher(ILogger<LoggingPublisher> logger) : IMessagePublisher
{
public Task PublishAsync(string messageId, string type, string payload, CancellationToken ct)
{
logger.LogInformation("Published {Type} {MessageId}", type, messageId);
return Task.CompletedTask;
}
}
// Thrown by the publisher when the broker itself is unreachable: not the message's fault.
public sealed class BrokerUnavailableException(string message, Exception? inner = null) : Exception(message, inner);
public sealed class OutboxRelay(IServiceScopeFactory scopes, IMessagePublisher publisher, ILogger<OutboxRelay> logger) : BackgroundService
{
private const int BatchSize = 50;
private const int MaxAttempts = 10;
private static readonly TimeSpan PublishTimeout = TimeSpan.FromSeconds(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var brokerBackoff = TimeSpan.FromSeconds(1);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
// Drain a backlog without waiting, but only while whole batches publish.
while (await RelayBatchAsync(stoppingToken) == BatchSize) { }
brokerBackoff = TimeSpan.FromSeconds(1);
}
catch (BrokerUnavailableException ex)
{
// No attempts were counted: an outage must not turn the backlog into poison rows.
logger.LogWarning(ex, "Broker unavailable; pausing the relay for {Delay}", brokerBackoff);
await Task.Delay(brokerBackoff, stoppingToken);
brokerBackoff = TimeSpan.FromTicks(Math.Min(brokerBackoff.Ticks * 2, TimeSpan.FromMinutes(1).Ticks));
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(ex, "Outbox relay pass failed");
}
}
}
private async Task<int> RelayBatchAsync(CancellationToken ct)
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OrdersDb>();
// With EnableRetryOnFailure a transaction begun outside the strategy throws on its first command.
// A retried batch reruns from the top, so rows published before the failure go out again:
// the at-least-once delivery the consumers already de-duplicate.
var strategy = db.Database.CreateExecutionStrategy();
var (published, brokerDown) = await strategy.ExecuteAsync(async () =>
{
db.ChangeTracker.Clear();
await using var tx = await db.Database.BeginTransactionAsync(ct);
var batch = await db.OutboxMessages.FromSql($"""
SELECT * FROM outbox_messages
WHERE processed_at IS NULL AND attempts < {MaxAttempts}
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
ORDER BY occurred_at
LIMIT {BatchSize}
FOR UPDATE SKIP LOCKED
""").ToListAsync(ct);
var count = 0;
BrokerUnavailableException? down = null;
foreach (var message in batch)
{
// The row locks are held during the round trip, so a hung broker call must not wait forever.
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(PublishTimeout);
try
{
await publisher.PublishAsync(message.Id.ToString(), message.Type, message.Payload, timeout.Token);
message.ProcessedAt = DateTimeOffset.UtcNow;
count++;
}
catch (Exception ex) when (ex is BrokerUnavailableException || (ex is OperationCanceledException && !ct.IsCancellationRequested))
{
// The broker is down or hung: stop the batch and leave the remaining rows untouched.
down = ex as BrokerUnavailableException ?? new BrokerUnavailableException("Publish timed out", ex);
break;
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
// A problem with this message (rejected, unroutable): count it and back it off.
message.Attempts++;
message.LastError = ex.Message;
message.NextAttemptAt = DateTimeOffset.UtcNow.AddSeconds(Math.Min(Math.Pow(2, message.Attempts), 600));
}
}
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return (count, down);
});
// Thrown after the commit, outside the strategy: marks already made are kept and nothing is retried.
if (brokerDown is not null) throw brokerDown;
return published;
}
}When to use it
- Publish OrderPlaced to RabbitMQ only if the order row committed, with no lost or phantom events when a pod is killed mid-request.
- Run the relay on three API replicas without double-publishing a batch, using FOR UPDATE SKIP LOCKED.
- Alert on-call when an InvoiceIssued message has failed ten publish attempts, with its last error stored on the row.
More examples
The outbox table and its partial index
Version 7 GUIDs are time-ordered, so the primary key does not fragment. The partial index covers only pending rows, so the relay's poll stays an index range scan of a few rows even when the table holds millions of processed ones. The last query is the stuck-message alert.
CREATE TABLE outbox_messages (
id uuid PRIMARY KEY,
type varchar(200) NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
processed_at timestamptz NULL,
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NULL, -- backoff for message-specific failures
last_error text NULL
);
-- The relay only ever reads unprocessed rows; keep that index tiny.
CREATE INDEX ix_outbox_unprocessed
ON outbox_messages (occurred_at)
WHERE processed_at IS NULL;
-- Rows that exhausted their attempts, for the on-call dashboard.
SELECT id, type, attempts, last_error
FROM outbox_messages
WHERE processed_at IS NULL AND attempts >= 10
ORDER BY occurred_at;Turn domain events into outbox rows in an interceptor
Entities raise events (<code>order.DomainEvents.Add(new OrderShipped(...))</code>) and the interceptor writes them to the outbox inside the same <code>SaveChanges</code> call, so no handler can save state and forget the event. Register it with <code>options.AddInterceptors(...)</code> in <code>AddDbContext</code>.
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public interface IHasDomainEvents
{
List<object> DomainEvents { get; }
}
public sealed class OutboxRow
{
public Guid Id { get; set; }
public string Type { get; set; } = "";
public string Payload { get; set; } = "";
public DateTimeOffset OccurredAt { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
}
public sealed class DomainEventsToOutbox(TimeProvider time) : SaveChangesInterceptor
{
// Both overrides: a synchronous SaveChanges() call must not skip the outbox.
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
AddOutboxRows(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
AddOutboxRows(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void AddOutboxRows(DbContext? context)
{
if (context is { } db)
{
var sources = db.ChangeTracker.Entries<IHasDomainEvents>().Select(e => e.Entity).ToList();
foreach (var entity in sources)
{
foreach (var evt in entity.DomainEvents)
{
db.Set<OutboxRow>().Add(new OutboxRow
{
Id = Guid.CreateVersion7(),
Type = evt.GetType().Name,
Payload = JsonSerializer.Serialize(evt, evt.GetType(), JsonSerializerOptions.Web),
OccurredAt = time.GetUtcNow(),
});
}
entity.DomainEvents.Clear();
}
}
}
}Purge processed rows in batches
<code>ExecuteDeleteAsync</code> runs a single set-based <code>DELETE</code> without loading rows. Deleting a month of rows in one statement holds locks and bloats the write-ahead log; batches of 5,000 keep each transaction short. Running it on every replica is harmless because the delete is idempotent.
using Microsoft.EntityFrameworkCore;
public sealed class OutboxEntry
{
public Guid Id { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
}
public sealed class OutboxDb(DbContextOptions<OutboxDb> options) : DbContext(options)
{
public DbSet<OutboxEntry> Outbox => Set<OutboxEntry>();
}
public sealed class OutboxCleanup(IServiceScopeFactory scopes, TimeProvider time, ILogger<OutboxCleanup> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1), time);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await using var scope = scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OutboxDb>();
var cutoff = time.GetUtcNow().AddDays(-7);
int deleted, total = 0;
do
{
// Small batches keep each DELETE short, so it never blocks the relay for long.
var ids = db.Outbox.Where(m => m.ProcessedAt < cutoff).OrderBy(m => m.Id).Select(m => m.Id).Take(5_000);
deleted = await db.Outbox.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync(stoppingToken);
total += deleted;
} while (deleted > 0);
logger.LogInformation("Purged {Count} outbox rows processed before {Cutoff:O}", total, cutoff);
}
}
}
Discussion