Many .NET applications need to update a database and publish an event to a message broker as part of the same business operation. For example, an order service may save a new order in SQL Server and publish an OrderCreated event to Kafka so that inventory, payment, and notification services can react to it.
The problem is that a database transaction and a message broker operation are usually separate operations. A database update may succeed while message publishing fails. The reverse can also happen: a message may be published, but the database transaction may roll back.
These failures can leave different services with inconsistent information.
The transactional outbox pattern addresses this problem by storing the business change and the message to be published in the same database transaction. A separate background process reads the stored messages and publishes them to the message broker.
This article explains how to implement the transactional outbox pattern in .NET, how to process pending messages, and what to consider when handling retries and duplicate delivery.
Why Database Transactions and Message Publishing Can Fail
Consider an ASP.NET Core order service that saves an order and publishes an event.
public async Task CreateOrderAsync(
Order order,
CancellationToken cancellationToken)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
await _messagePublisher.PublishAsync(
new OrderCreated(order.Id),
cancellationToken);
}This code looks straightforward, but it contains a consistency problem.
Suppose SaveChangesAsync() succeeds, but Kafka is temporarily unavailable when PublishAsync() runs. The order exists in the database, but downstream services never receive the event.
Another problem can occur if the message is published before the database transaction commits. A consumer might receive an event for an order that is not yet visible in the database. If the database transaction subsequently fails, the event refers to a change that never became permanent.
Using a database transaction around SaveChangesAsync() does not automatically include a separate Kafka publish operation in that transaction.
You could try to coordinate both systems with a distributed transaction, but that introduces additional complexity and requires compatible transaction support. The outbox pattern provides a practical alternative for many event-driven applications.
How the Transactional Outbox Pattern Works
The pattern introduces an outbox table in the same database as the business data.
The application performs two writes in one database transaction:
- Save the business entity, such as an order.
- Save an outbox message describing the change.
If the transaction commits, both records are saved. If it rolls back, neither record is saved.
A background worker then reads unpublished outbox messages and sends them to the message broker.
<text color="secondary" size="sm">The basic flow</text>
<box border radius="lg" padding={3} gap={2}> <box background="surface-secondary" radius="md" padding={3} align="center"> ASP.NET Core order service
<text size="sm" color="secondary">Create order and outbox message</text></box> <box align="center"> <icon name="arrow-down" size="lg" color="secondary" /> </box> <box border radius="md" padding={3} align="center"> One database transaction
<text size="sm" color="secondary">Orders table + Outbox table</text></box> <box align="center"> <icon name="arrow-down" size="lg" color="secondary" /> </box> <box background="surface-secondary" radius="md" padding={3} align="center"> Background outbox worker
<text size="sm" color="secondary">Read pending messages and publish</text></box> <box align="center"> <icon name="arrow-down" size="lg" color="secondary" /> </box> <box border radius="md" padding={3} align="center"> Message broker
<text size="sm" color="secondary">Kafka, RabbitMQ, or another broker</text></box> </box>
The database transaction protects the relationship between the business change and the intent to publish an event. It does not make the database and the broker one atomic transaction. The worker still needs retry logic, and consumers must account for duplicate delivery.
Create an Outbox Table
The outbox table stores messages until the application successfully publishes them.
The following example uses SQL Server:
CREATE TABLE dbo.OutboxMessages
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_OutboxMessages PRIMARY KEY,
MessageType NVARCHAR(200) NOT NULL,
Payload NVARCHAR(MAX) NOT NULL,
CreatedAtUtc DATETIME2 NOT NULL,
ProcessedAtUtc DATETIME2 NULL,
Attempts INT NOT NULL
CONSTRAINT DF_OutboxMessages_Attempts DEFAULT 0,
LastError NVARCHAR(2000) NULL
);
CREATE INDEX IX_OutboxMessages_Pending
ON dbo.OutboxMessages (CreatedAtUtc, Id)
WHERE ProcessedAtUtc IS NULL;Each row represents a message waiting to be published or a message that has already been processed.
The Id provides a unique identifier that can help detect duplicate delivery. MessageType identifies the event type, and Payload stores the serialized message. CreatedAtUtc records when the message was created.
The ProcessedAtUtc column indicates that the worker has successfully completed its publishing step. Attempts and LastError provide basic retry diagnostics.
For a production implementation, you may also need a lease or lock field, a next-retry timestamp, a dead-letter status, and a concurrency-control mechanism. Those fields become particularly important when multiple worker instances process the same outbox table.
Define the Outbox Entity in .NET
Create a corresponding entity for Entity Framework Core:
public sealed class OutboxMessage
{
public Guid Id { get; set; }
public string MessageType { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTime CreatedAtUtc { get; set; }
public DateTime? ProcessedAtUtc { get; set; }
public int Attempts { get; set; }
public string? LastError { get; set; }
}Add the entity to your database context:
using Microsoft.EntityFrameworkCore;
public sealed class OrdersDbContext : DbContext
{
public OrdersDbContext(
DbContextOptions<OrdersDbContext> options)
: base(options)
{
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<OutboxMessage> OutboxMessages =>
Set<OutboxMessage>();
}Configure the entity mapping and generate a database migration to create the table and filtered index. Make sure the EF Core model matches the actual SQL schema, including the primary key, column lengths, and nullability.
Save the Order and Outbox Message in One Transaction
Now update the order creation logic so that it saves the order and its event together.
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public sealed record OrderCreated(
Guid OrderId,
decimal Total);
public sealed class OrderService
{
private readonly OrdersDbContext _db;
public OrderService(OrdersDbContext db)
{
_db = db;
}
public async Task<Guid> CreateOrderAsync(
decimal total,
CancellationToken cancellationToken)
{
await using var transaction =
await _db.Database.BeginTransactionAsync(
cancellationToken);
var order = new Order
{
Id = Guid.NewGuid(),
Total = total
};
var orderCreated = new OrderCreated(
order.Id,
order.Total);
var outboxMessage = new OutboxMessage
{
Id = Guid.NewGuid(),
MessageType = nameof(OrderCreated),
Payload = JsonSerializer.Serialize(orderCreated),
CreatedAtUtc = DateTime.UtcNow,
Attempts = 0
};
_db.Orders.Add(order);
_db.OutboxMessages.Add(outboxMessage);
await _db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return order.Id;
}
}
Join the conversation! Your thoughts help the community grow.