The transactional outbox saves an outgoing message in the same database transaction as the business change. In an EF Core application, a separate dispatcher publishes those committed messages to the broker. This closes the database-to-broker dual-write gap, but consumers still need to handle duplicates and delivery failures need monitoring.
What Problem Does the Outbox Solve?
An order service has two operations: insert an order and publish OrderPlaced.
If it saves first, a crash before publishing leaves an order with no event.
If it publishes first, a database failure leaves an event for an order that never committed.
The outbox pattern moves the message into the database transaction. The request succeeds when the order and the intent to publish are durable. It does not wait for inventory, payments, or any other consumer to finish.
A dispatcher can retry pending messages after a restart. Eventual delivery still depends on the database retaining the rows, the broker recovering, and someone resolving messages that cannot be published. An outbox is not a guarantee that every downstream business operation succeeds.
A Complete EF Core Bus Outbox Example
MassTransit provides a transactional EF Core outbox. The example below uses the bus outbox, which captures messages published from an application scope such as an HTTP request. Its consumer outbox is configured separately on receive endpoints.
Create a .NET 10 application with PostgreSQL and RabbitMQ support:
dotnet new web -n OutboxDemo -f net10.0
cd OutboxDemo
dotnet add package MassTransit.RabbitMQ --version 8.5.11
dotnet add package MassTransit.EntityFrameworkCore --version 8.5.11
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.0
These examples pin MassTransit 8.5.11 so the package APIs are reproducible. Review the support and licensing requirements of your chosen MassTransit version before adopting it.
For a local demonstration, save this as compose.yaml and run docker compose up -d:
services:
postgres:
image: postgres:18
environment:
POSTGRES_DB: outbox_demo
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- '5432:5432'
rabbitmq:
image: rabbitmq:4
ports:
- '5672:5672'
Replace Program.cs with the following code.
The client supplies the order ID so a retried request cannot create a second order with the same ID:
using MassTransit;
using Microsoft.EntityFrameworkCore;
using OutboxDemo.Contracts;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<OrderDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Orders")
?? "Host=localhost;Database=outbox_demo;Username=postgres;Password=postgres"));
builder.Services.AddMassTransit(x =>
{
x.AddEntityFrameworkOutbox<OrderDbContext>(o =>
{
o.UsePostgres();
o.UseBusOutbox();
o.QueryDelay = TimeSpan.FromSeconds(1);
});
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost", "/", h =>
{
h.Username("guest");
h.Password("guest");
});
cfg.ConfigureEndpoints(context);
});
});
var app = builder.Build();
// Demo schema setup only. Use reviewed EF migrations in production.
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<OrderDbContext>();
await db.Database.EnsureCreatedAsync();
}
app.MapPost("/orders", async (
PlaceOrder request,
OrderDbContext db,
IPublishEndpoint publisher,
CancellationToken ct) =>
{
if (request.OrderId == Guid.Empty || request.TotalAmount <= 0)
return Results.BadRequest();
if (await db.Orders.AnyAsync(x => x.Id == request.OrderId, ct))
return Results.Conflict("Order ID already exists.");
var order = new Order
{
Id = request.OrderId,
TotalAmount = request.TotalAmount,
CreatedAt = DateTime.UtcNow
};
db.Orders.Add(order);
// The scoped bus outbox captures this. Publish BEFORE SaveChanges.
await publisher.Publish(new OrderPlaced(
order.Id, order.TotalAmount, order.CreatedAt), ct);
await db.SaveChangesAsync(ct);
return Results.Created($"/orders/{order.Id}", order);
});
app.MapGet("/orders/{id:guid}", async (
Guid id, OrderDbContext db, CancellationToken ct) =>
await db.Orders.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, ct)
is { } order ? Results.Ok(order) : Results.NotFound());
app.Run();
public record PlaceOrder(Guid OrderId, decimal TotalAmount);
public class Order
{
public Guid Id { get; set; }
public decimal TotalAmount { get; set; }
public DateTime CreatedAt { get; set; }
}
public class OrderDbContext(DbContextOptions<OrderDbContext> options)
: DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>().Property(x => x.TotalAmount)
.HasPrecision(18, 2);
modelBuilder.AddInboxStateEntity();
modelBuilder.AddOutboxMessageEntity();
modelBuilder.AddOutboxStateEntity();
}
}
namespace OutboxDemo.Contracts
{
public record OrderPlaced(Guid OrderId, decimal TotalAmount, DateTime CreatedAt);
}
The EF Core transaction guarantee applies to the changes in one SaveChangesAsync call when the provider supports transactions.
Here that includes the order and MassTransit's captured messages.
The primary key also rejects concurrent requests with the same order ID; a production API should translate that specific unique-constraint failure into its duplicate-request response.
The pre-check alone does not provide idempotency. For retries that must return the original response, persist the request hash and response alongside the order and reject reuse of the key with a different payload. See idempotency key patterns.
Run the application with dotnet run --urls http://localhost:5080 and submit an order:
curl -X POST http://localhost:5080/orders \
-H "Content-Type: application/json" \
-d '{"orderId":"11111111-1111-1111-1111-111111111111","totalAmount":49.00}'
This app demonstrates the producer transaction. Before publishing events in a real deployment, provision the consumer endpoints or broker bindings that should retain them. A successful publish to an exchange with no interested queues does not create a future subscription backlog.
What the Bus Outbox Does
The scoped IPublishEndpoint writes messages into the configured DbContext instead of sending them immediately.
After the transaction commits, MassTransit's delivery service sends those messages to RabbitMQ.
Use the scoped endpoint from the request's service provider; publishing directly through IBus bypasses that capture.
The registration does not create database tables by itself. The model needs the inbox, outbox message, and outbox state entities, followed by schema creation or migrations. The MassTransit outbox documentation describes those entities and the separate bus and consumer outbox options.
The bus outbox does not make every receiving service idempotent.
DuplicateDetectionWindow controls inbox retention for the consumer outbox; it is not a global duplicate filter for published messages.
Why Consumers Still Need Idempotency
A dispatcher can publish successfully and crash before recording that success in its database. After recovery it publishes the message again. That is at-least-once delivery.
Use a stable message ID across dispatch attempts. At the consumer, store the processed ID and the business update in the same local transaction, with a unique constraint enforcing the ID. A read-then-insert check without a constraint races when two deliveries run concurrently.
The inbox pattern handles this database boundary. An external payment request still needs its own provider idempotency key and reconciliation path; the consumer's local transaction cannot roll back that HTTP call.
If You Build the Dispatcher Yourself
A custom implementation needs more than a timer querying unprocessed rows. Its behavior must be explicit when a process dies, a second worker starts, or a message cannot be deserialized.
- Claim rows atomically. PostgreSQL's
FOR UPDATE SKIP LOCKEDcan divide work between dispatchers, but the lock lasts only for the transaction. Selecting under a lock and publishing after committing that transaction does not retain ownership. An alternative is a durable claim with an expiry and an ownership token. - Bound a publish attempt. Pass cancellation and a timeout to broker operations. A worker holding a database transaction open during broker I/O also holds locks and a connection, so keep batches small and account for broker outages.
- Keep the message ID stable. Use the outbox row ID as the broker message ID on every attempt. A fresh ID defeats inbox deduplication.
- Use a stable contract name. Map a name such as
order-placed-v1to an allowlisted type. An assembly-qualified CLR name couples queued data to deployment details. - Make failure state actionable. Store attempt count, next attempt time, and a bounded error description. Quarantine poison messages and alert on them; logging "maximum retries exceeded" while continuing to poll the same row is not a retry limit.
Parallel dispatchers can publish later events before earlier events for the same entity. Row locking prevents simultaneous claims, but it does not establish business ordering. Use per-entity sequences and partitioning when consumers depend on an ordered stream.
Capturing Domain Events With an Interceptor
An EF Core interceptor can convert domain events into outbox rows before saving. It should replace manual capture for that event, or the request will enqueue it twice.
Give the domain event a stable ID before capture.
Handle serialization failures without clearing uncaptured events, and define what happens when SaveChangesAsync fails and the caller retries.
A destructive PopDomainEvents() followed by serialization can lose events if serialization throws halfway through the list.
Also decide whether the application permits synchronous SaveChanges.
Overriding only SavingChangesAsync does not intercept that path.
For many applications, explicit capture in the application service is easier to audit than an interceptor with several retry and lifecycle rules.
Delivery Checks and Cleanup
Test recovery with the broker stopped: submit an order, confirm that the order and its outbox data commit, then restart the broker and check delivery. Also test a process failure after publish but before the dispatch checkpoint to prove that the consumer handles redelivery.
Monitor pending message count, the age of the oldest pending message, dispatch failures, and quarantine count. Consumer lag is a separate signal: an empty producer outbox does not mean inventory has processed every order.
Delete only messages whose delivery has completed, after the retention period required for investigation. Never delete old pending messages as a cleanup shortcut. Scaling the outbox pattern covers the indexing, batching, and cleanup concerns as traffic grows.
Summary
Persist the business write and the outgoing message together, then verify the complete recovery path. The transaction closes the dual-write gap; dispatch operations and consumer idempotency keep it closed during failures.
Frequently Asked Questions
What problem does the transactional outbox pattern solve?
It solves the dual write problem: saving data to a database and publishing a message are two separate operations that can fail independently. The outbox stores the message in the same database transaction as the data, so the business write and the intent to publish commit together or neither does.
Does the outbox pattern guarantee exactly-once delivery?
No. Delivery retries can produce duplicates. If the processor crashes after publishing but before marking a message as processed, the message is published again. Consumers must be idempotent to handle duplicates.
How often should the outbox processor run?
A polling interval of 1 to 5 seconds is typical. Shorter intervals reduce publishing latency but increase database load. MassTransit uses a similar polling approach with a configurable QueryDelay.
Can I run multiple instances of the outbox processor?
Yes, but naive polling will publish duplicates because both instances pick up the same rows. Use row locking with SKIP LOCKED semantics, a distributed lock, or run the processor on a single instance.
Should I build my own outbox or use MassTransit?
For production systems, use the MassTransit transactional outbox. It supplies delivery processing, and its separately configured consumer outbox supports duplicate detection and inbox cleanup. Build your own only if you are not using a message bus library or want full control.

