Transactional Outbox Pattern in .NET with EF Core (.NET 10): Fix the Dual-Write Problem
Write business state and integration events in one database transaction, publish them safely after commit, and make duplicate delivery harmless.
Published Updated 49 min readIntermediate–Advanced
What You Will Build
Build an Orders API that commits an order and an integration event together, publishes the event later through a relay, and safely ignores duplicate deliveries in the consumer.
POST /api/orders — create an order and its outbox message
GET /api/orders/{id} — read an order
GET /api/outbox — inspect pending and processed messages in development
GET /health — application and database health
Orders API
│
├── INSERT Order ─────────────┐
└── INSERT OutboxMessage ─────┴── one database transaction
│
▼
Outbox dispatcher
│
▼
RabbitMQ
│
▼
Idempotent consumer + inbox
✅
Verified target: .NET 10 LTS and EF Core 10. The outbox itself is a pattern, not a framework feature. This tutorial implements it by hand and keeps the broker behind an interface. Last reviewed: July 22, 2026.
The Dual-Write Problem: Where Messages Get Lost
A normal request often needs to change the database and notify another service. Those are two independent systems. They cannot be committed with one ordinary local transaction.
csharp
app.MapPost("/api/orders", async (
CreateOrderRequest request,
OrdersDbContext db,
IMessagePublisher publisher,
CancellationToken cancellationToken) =>
{
var order = Order.Create(request.CustomerId, request.Total);
db.Orders.Add(order);
await db.SaveChangesAsync(cancellationToken);
// Failure window: the order is committed, but publishing may fail.
await publisher.PublishAsync(
new OrderPlaced(order.Id, order.CustomerId, order.Total),
cancellationToken);
return Results.Created($"/api/orders/{order.Id}", order);
});
Failure window 1: the database commits and the process crashes before publishing. The order exists, but the event is never sent.
Failure window 2: publishing happens first, then the database write fails. Other services receive an event for an order that does not exist.
A try/catch cannot make two independent systems atomic. The transactional outbox changes the problem: write the event to the same database transaction as the business state, then publish it asynchronously.
Prerequisites
.NET 10 SDK and an IDE.
Docker Desktop or Docker Engine.
Basic EF Core migration experience.
Familiarity with BackgroundService and cancellation tokens.
A basic publish/consume mental model.
Reading level: the tutorial explains each distributed-systems rule before introducing the implementation. You do not need previous outbox experience.
public sealed class OutboxMessage
{
public Guid Id { get; init; }
public string Type { get; init; } = string.Empty;
public string Payload { get; init; } = string.Empty;
public DateTimeOffset OccurredOnUtc { get; init; }
public DateTimeOffset? ProcessedOnUtc { get; set; }
public int Attempts { get; set; }
public string? Error { get; set; }
public string? TraceParent { get; init; }
public DateTimeOffset? NextAttemptOnUtc { get; set; }
public static OutboxMessage From<T>(
T message,
string? traceParent,
JsonSerializerOptions jsonOptions)
{
ArgumentNullException.ThrowIfNull(message);
return new OutboxMessage
{
Id = Guid.NewGuid(),
Type = typeof(T).FullName
?? throw new InvalidOperationException("Message type has no full name."),
Payload = JsonSerializer.Serialize(message, jsonOptions),
OccurredOnUtc = DateTimeOffset.UtcNow,
TraceParent = traceParent
};
}
}
The row id is also the message id sent to the broker. That stable id gives the consumer a reliable deduplication key.
Why store payload and type? The relay should not need the original aggregate. It reads a self-contained integration event from the outbox and publishes it later.
Write Business State and the Outbox Row Atomically
csharp
public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal Total,
DateTimeOffset OccurredOnUtc);
app.MapPost("/api/orders", async (
CreateOrderRequest request,
OrdersDbContext db,
JsonSerializerOptions jsonOptions,
CancellationToken cancellationToken) =>
{
var order = Order.Create(request.CustomerId, request.Total);
var integrationEvent = new OrderPlaced(
order.Id,
order.CustomerId,
order.Total,
DateTimeOffset.UtcNow);
db.Orders.Add(order);
db.OutboxMessages.Add(OutboxMessage.From(
integrationEvent,
Activity.Current?.Id,
jsonOptions));
// One SaveChanges call means EF Core commits both rows together.
await db.SaveChangesAsync(cancellationToken);
return Results.Created($"/api/orders/{order.Id}", new { order.Id });
});
A single SaveChangesAsync call is already transactional for the changes it sends. The explicit transaction in the next section becomes useful when the operation includes multiple saves or other database work that must be retried as one unit.
Do not use two SaveChanges calls for the order and outbox row unless both are enclosed in the same correctly managed transaction. Two independent commits recreate the original failure window.
Wrap Explicit Transactions in EF Core’s Execution Strategy
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
db.Orders.Add(order);
db.OutboxMessages.Add(outboxMessage);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
});
Common setup error: retrying execution strategies do not support a manually started transaction unless the whole transaction is executed through the strategy. Otherwise EF Core throws an exception explaining that the configured strategy does not support user-initiated transactions.
The execution strategy may run the delegate again after a transient failure. Keep the work deterministic and avoid external side effects, such as publishing to RabbitMQ, inside this delegate.
Capture Events Automatically with a SaveChangesInterceptor
csharp
public interface IDomainEvent
{
DateTimeOffset OccurredOnUtc { get; }
}
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents;
protected void Raise(IDomainEvent domainEvent) =>
_domainEvents.Add(domainEvent);
public IReadOnlyCollection<IDomainEvent> DequeueDomainEvents()
{
IDomainEvent[] events = [.. _domainEvents];
_domainEvents.Clear();
return events;
}
}
csharp
public sealed class OutboxInterceptor(
JsonSerializerOptions jsonOptions)
: SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not OrdersDbContext db)
{
return base.SavingChangesAsync(
eventData,
result,
cancellationToken);
}
var events = db.ChangeTracker
.Entries<AggregateRoot>()
.SelectMany(entry => entry.Entity.DequeueDomainEvents())
.ToArray();
foreach (IDomainEvent domainEvent in events)
{
db.OutboxMessages.Add(OutboxMessage.From(
domainEvent,
Activity.Current?.Id,
jsonOptions));
}
return base.SavingChangesAsync(
eventData,
result,
cancellationToken);
}
}
The interceptor centralizes capture. Business code raises a domain event; the interceptor translates it into an outbox row immediately before EF Core saves the transaction.
Important retry detail: dequeueing events before a failed save can lose them in memory if the same context is reused incorrectly. Keep request-scoped contexts short-lived, and test retry behavior. A production implementation may snapshot events and clear them only after a successful save.
Build the Relay as a BackgroundService
csharp
public sealed class OutboxDispatcher(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
IOptions<OutboxOptions> options,
ILogger<OutboxDispatcher> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(
TimeSpan.FromSeconds(options.Value.PollingIntervalSeconds),
timeProvider);
do
{
try
{
await DispatchBatchAsync(stoppingToken);
}
catch (OperationCanceledException)
when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Outbox dispatch failed.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task DispatchBatchAsync(CancellationToken cancellationToken)
{
await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
var service = scope.ServiceProvider
.GetRequiredService<OutboxBatchProcessor>();
await service.ProcessAsync(cancellationToken);
}
}
The dispatcher creates a new dependency-injection scope for each batch. That gives each batch a fresh DbContext and prevents a long-running worker from holding stale tracked entities.
Publish Through a Broker-Agnostic Abstraction
csharp
public interface IMessagePublisher
{
Task PublishAsync(
PublishedMessage message,
CancellationToken cancellationToken);
}
public sealed record PublishedMessage(
Guid MessageId,
string Type,
ReadOnlyMemory<byte> Body,
string? TraceParent);
csharp
public sealed class RabbitMqPublisher(
IConnection connection,
IOptions<RabbitMqOptions> options)
: IMessagePublisher
{
public async Task PublishAsync(
PublishedMessage message,
CancellationToken cancellationToken)
{
await using IChannel channel =
await connection.CreateChannelAsync(cancellationToken: cancellationToken);
var properties = new BasicProperties
{
MessageId = message.MessageId.ToString("D"),
Type = message.Type,
Persistent = true,
Headers = message.TraceParent is null
? null
: new Dictionary<string, object?>
{
["traceparent"] = message.TraceParent
}
};
await channel.BasicPublishAsync(
exchange: options.Value.Exchange,
routingKey: message.Type,
mandatory: true,
basicProperties: properties,
body: message.Body,
cancellationToken: cancellationToken);
}
}
The application and relay depend on IMessagePublisher, not RabbitMQ SDK types. Azure Service Bus, Kafka, or another transport can be introduced behind the same application-facing contract.
Scale to Concurrent Dispatchers Safely
sql
SELECT "Id", "Type", "Payload", "OccurredOnUtc", "TraceParent"
FROM "OutboxMessages"
WHERE "ProcessedOnUtc" IS NULL
AND ("NextAttemptOnUtc" IS NULL OR "NextAttemptOnUtc" <= now())
ORDER BY "OccurredOnUtc"
FOR UPDATE SKIP LOCKED
LIMIT @batch_size;
csharp
public async Task ProcessAsync(CancellationToken cancellationToken)
{
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
List<OutboxMessage> messages = await db.OutboxMessages
.FromSqlInterpolated($"""
SELECT *
FROM "OutboxMessages"
WHERE "ProcessedOnUtc" IS NULL
AND ("NextAttemptOnUtc" IS NULL
OR "NextAttemptOnUtc" <= now())
ORDER BY "OccurredOnUtc"
FOR UPDATE SKIP LOCKED
LIMIT {options.Value.BatchSize}
""")
.ToListAsync(cancellationToken);
foreach (OutboxMessage message in messages)
{
await PublishOneAsync(message, cancellationToken);
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
});
}
SKIP LOCKED lets several relay instances select different rows without waiting for one another. PostgreSQL documents this as suitable for queue-like access, not for a general consistent view.
SQL Server equivalent: use locking hints such as UPDLOCK, READPAST, and usually ROWLOCK within a transaction. Test the exact query under your isolation level.
This still does not create exactly-once publishing. A process can publish successfully and crash before setting ProcessedOnUtc. The row will be published again after restart.
Make Consumers Idempotent with an Inbox
csharp
public sealed class ProcessedMessage
{
public Guid MessageId { get; init; }
public DateTimeOffset ProcessedOnUtc { get; init; }
}
modelBuilder.Entity<ProcessedMessage>(entity =>
{
entity.HasKey(x => x.MessageId);
});
The unique primary key on MessageId is the final protection against two consumer instances racing. Catch the provider-specific unique-constraint exception and treat it as “already processed.”
Rule: the inbox record and the consumer’s side effect must commit together. Recording the id before or after a separate commit creates another dual-write problem.
A poison message is one that will never publish successfully without intervention—for example, an invalid contract or oversized payload. Do not let it block every newer message.
Increment attempts and record a short error.
Use bounded exponential backoff for transient failures.
Quarantine after a configured limit.
Provide an operator path to inspect, repair, and replay.
Monitor more than error logs. The most useful outbox signals are:
Pending row count.
Age of the oldest pending row.
Publish success and failure count.
Attempts per message.
Batch duration and broker latency.
Dead-letter count.
Do not log full payloads by default. Integration events may contain customer or commercial data. Prefer message id, type, age, and error category.
Faster Than Polling: Push-then-Poll and CDC
A five-second polling interval is simple and reliable, but it adds latency. A useful optimization is push-then-poll:
Commit the business transaction.
Signal an in-process Channel<bool>.
The relay wakes immediately and drains the outbox.
A periodic poll remains as the recovery path if a signal is missed or the process restarts.
csharp
public sealed class OutboxSignal
{
private readonly Channel<bool> _channel = Channel.CreateBounded<bool>(
new BoundedChannelOptions(1)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false
});
public void Notify() => _channel.Writer.TryWrite(true);
public ValueTask<bool> WaitAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAsync(cancellationToken);
}
For very high throughput, Change Data Capture tools such as Debezium can read committed outbox rows from the database log and publish them without application polling. That is a different operational model and is intentionally outside this hands-on implementation.
Test the Failure Modes
csharp
[Fact]
public async Task Creating_order_writes_order_and_outbox_together()
{
await using OrdersDbContext db = CreateDbContext();
var order = Order.Create(Guid.NewGuid(), 125m);
db.Orders.Add(order);
db.OutboxMessages.Add(CreateOutboxMessage(order));
await db.SaveChangesAsync();
Assert.True(await db.Orders.AnyAsync(x => x.Id == order.Id));
Assert.True(await db.OutboxMessages.AnyAsync());
}
[Fact]
public async Task Duplicate_message_is_applied_once()
{
Guid messageId = Guid.NewGuid();
var message = new OrderPlaced(
Guid.NewGuid(), Guid.NewGuid(), 125m, DateTimeOffset.UtcNow);
await handler.HandleAsync(messageId, message, CancellationToken.None);
await handler.HandleAsync(messageId, message, CancellationToken.None);
Assert.Equal(1, await db.OrderNotifications.CountAsync());
Assert.Equal(1, await db.ProcessedMessages.CountAsync());
}
Use real PostgreSQL and RabbitMQ containers for integration tests. EF Core InMemory cannot reproduce transactions, relational locks, provider errors, or SKIP LOCKED.
Broker down while the API commits.
Transaction rollback produces no order and no outbox row.
Relay restart drains pending rows.
Crash after publish causes a duplicate.
Inbox ignores that duplicate.
Poison message is quarantined after the limit.
Two dispatchers claim different rows.
Run the Failure Demonstration
bash
docker compose up -d
dotnet ef database update --project src/OutboxOrders.Api --startup-project src/OutboxOrders.Api
dotnet run --project src/OutboxOrders.Api
dotnet run --project src/OutboxOrders.Consumer
bash
# Create an order while RabbitMQ is running.
curl -k -X POST https://localhost:7001/api/orders -H "Content-Type: application/json" -d '{"customerId":"11111111-1111-1111-1111-111111111111","total":125.00}'
# Stop the broker, then create another order.
docker compose stop rabbitmq
curl -k -X POST https://localhost:7001/api/orders -H "Content-Type: application/json" -d '{"customerId":"22222222-2222-2222-2222-222222222222","total":75.00}'
# The API succeeds because the database transaction does not depend on RabbitMQ.
# Restart RabbitMQ and watch the relay publish the backlog.
docker compose start rabbitmq
The payoff: the caller succeeds while RabbitMQ is unavailable, the outbox row remains pending, and the relay sends it after RabbitMQ returns. Then force a duplicate and show the inbox ignoring it.
Hand-Rolled vs Library: Choose with the Facts
The code in this tutorial is intentionally hand-rolled so every reliability rule is visible. Production teams may prefer a library that also supplies broker integrations, retries, sagas, dashboards, and operational tooling.
Option
License
Outbox support
Use when
Hand-rolled
Your code
You implement it
The needs are focused and the team wants full control.
MassTransit v8
Apache 2.0
Built in
You already use v8 and accept its support horizon.
MassTransit v9+
Commercial
Built in
The commercial terms and support model fit the organization.
Wolverine
MIT
Durable outbox/inbox
You want messaging plus durable local queues and EF Core/Marten integration.
DotNetCore.CAP
MIT
Core design
You want an outbox-oriented event bus with common brokers.
Brighter
MIT
Available
You use command processing and messaging patterns.
Rebus
MIT
Available
You prefer a mature, lightweight service bus.
NServiceBus
Commercial
Built in
You need enterprise support and a broad messaging platform.
Licensing changes over time. Verify the current license, support window, and pricing immediately before selecting a library. Keep package versions in Directory.Packages.props, not in the article prose.
Production Readiness Checklist
Order and outbox row commit in one transaction.
Explicit transactions are wrapped in the EF Core execution strategy when retries are enabled.
Relay queries are safe under multiple application instances.
Every published message has a stable message id.
Consumers commit side effects and inbox records together.
Retries are bounded and poison messages are quarantined.
Processed rows are deleted or archived on a retention schedule.
Oldest-pending-message age is monitored and alerted.
Payloads are not written to ordinary logs.
Trace context is propagated to the broker and consumer.
Database and broker credentials come from a secret store.
Failure scenarios are covered by integration tests using real infrastructure.
What to Build Next
This implementation solves the producer-side dual write. Natural follow-up tutorials are:
Build a reusable inbox middleware for idempotent consumers.
Preserve ordering per aggregate or partition key.
Replace polling with Debezium change-data capture.
Coordinate long-running work with a saga or process manager.
Add schema-versioning rules for integration events.
Remember: the outbox does not make the entire distributed system transactional. It makes the producer's database change and intent-to-publish atomic.
Frequently Asked Questions
Does the transactional outbox provide exactly-once delivery?
No. It provides eventual at-least-once delivery. If the relay publishes and crashes before marking the row processed, it publishes the same message again. Consumers must deduplicate by message id.
Why not publish inside the database transaction?
The database transaction cannot atomically commit RabbitMQ. Holding the transaction open during network I/O also increases lock time and still cannot remove the failure window.
Is a SaveChangesInterceptor required?
No. Explicitly adding an outbox row is valid and easy to understand. The interceptor is useful when many commands raise domain events and you want one central capture rule.
Can I test this with EF Core InMemory?
Not for the important behavior. Use the actual relational provider because transactions, locks, SQL syntax, and retry behavior are provider-specific.