# Transactional Outbox Pattern With EF Core

> The transactional outbox stores domain changes and outgoing messages in one EF Core transaction, closing the database-to-broker dual-write gap. The remaining work is a safe dispatcher, idempotent consumers, retry limits, and cleanup that holds up under failure.

Published: 2026-10-27. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/transactional-outbox-ef-core

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**](https://milanjovanovic.tech/blog/outbox-pattern-dual-write-problem) 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.

![The app writing the order and the outbox message in one database transaction, while a separate outbox processor polls unprocessed rows, publishes them to the broker, and marks them processed](https://milanjovanovic.tech/blogs/articles/transactional-outbox-ef-core/outbox-flow.png)

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**](https://masstransit.io/) 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:

```bash
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`:

```yaml
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:

```csharp
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**](https://learn.microsoft.com/en-us/ef/core/saving/transactions) 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:

```bash
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**](https://masstransit.io/documentation/configuration/middleware/outbox) 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**](https://milanjovanovic.tech/blog/implementing-the-inbox-pattern-for-reliable-message-consumption) 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 LOCKED` can 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-v1` to 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**](https://milanjovanovic.tech/blog/message-ordering-distributed-systems) when consumers depend on an ordered stream.

## Capturing Domain Events With an Interceptor

An [**EF Core interceptor**](https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors) 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**](https://milanjovanovic.tech/blog/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.
