Many .NET applications need to update a database and publish an event when a business operation succeeds. For example, an order service may save a new order in SQL Server and publish an OrderCreated event to Kafka or RabbitMQ so that other services can process the order.
The challenge is that the database and the message broker usually operate as separate systems. A database transaction cannot automatically guarantee that a message will be published successfully to an external broker.
This creates a reliability problem. The order may be saved successfully, but the application can crash before publishing the event. Alternatively, the event may be published, but the database transaction may fail. Either situation can leave the application and its dependent services with inconsistent information.
The transactional outbox pattern addresses this problem by storing the business change and the event to be published in the same database transaction. A separate background worker then reads pending events from the outbox and publishes them to the message broker.
In this article, we will implement the transactional outbox pattern in .NET using Entity Framework Core, understand how the background publisher works, and examine the failure scenarios that matter in production.
What Is the Transactional Outbox Pattern?
The transactional outbox pattern stores messages in a database table as part of the same transaction that updates business data.
Instead of trying to save an order and publish a message as two independent operations, the application performs the following steps:
Begin a database transaction.
Save the order.
Insert an event record into the outbox table.
Commit the transaction.
Let a background worker publish the event.
Mark the outbox record as processed after successful publication.
The key principle is that the business data and the outbox record are committed together.
If the transaction fails, neither change is committed. If the transaction succeeds, the application has durable evidence that the event still needs to be published.
This does not make the database transaction atomic with the message broker. Instead, it removes the dangerous gap between saving business data and recording the intention to publish a message.
Why Direct Message Publishing Can Fail
Consider a typical order creation method:
public async Task CreateOrderAsync(
Order order,
CancellationToken cancellationToken)
{
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync(
cancellationToken);
await _messagePublisher.PublishAsync(
new OrderCreated(order.Id),
cancellationToken);
}This code looks reasonable, but it has a reliability gap.
The database operation may succeed while message publication fails. If the application crashes after SaveChangesAsync completes, the order remains in the database, but no event may be delivered.
Reversing the order of operations does not solve the problem:
await _messagePublisher.PublishAsync(
new OrderCreated(order.Id),
cancellationToken);
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync(
cancellationToken);In this version, the message may be published even if the database transaction fails. Other services may start processing an order that does not exist in the database.
The problem is not simply the order of the two calls. The problem is that they belong to different transactional systems.
How the Outbox Pattern Works
The transactional outbox pattern separates the business transaction from message publication.
Step 1: Business request
The .NET application receives a request to create an order.
Step 2: Single database transaction
The application inserts the order and its corresponding outbox event into the database within the same transaction.
Step 3: Background publisher
A background worker reads pending events from the outbox table and publishes them to the message broker.
Step 4: Message broker
The broker delivers the event to the appropriate consumers.
The database transaction guarantees that the order and the outbox record are committed together. The background publisher handles the separate task of delivering the recorded event.
If the broker is temporarily unavailable, the outbox record remains available for a later retry.
Step 1: Create the Outbox Table
The outbox table stores the event payload and the information required to publish and track it.
The following SQL Server table is a simple starting point:
CREATE TABLE dbo.OutboxMessages
(
Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY,
Type NVARCHAR(300) NOT NULL,
Payload NVARCHAR(MAX) NOT NULL,
OccurredOnUtc DATETIME2 NOT NULL,
ProcessedOnUtc DATETIME2 NULL,
RetryCount INT NOT NULL DEFAULT 0,
Error NVARCHAR(2000) NULL
);
CREATE INDEX IX_OutboxMessages_Pending
ON dbo.OutboxMessages (OccurredOnUtc)
WHERE ProcessedOnUtc IS NULL;The columns serve different purposes:
Id uniquely identifies an outbox message.
Type identifies the event type.
Payload contains the serialized event.
OccurredOnUtc records when the application created the event.
ProcessedOnUtc records when publication was acknowledged and recorded as complete.
RetryCount tracks publication attempts.
Error stores a brief description of the latest failure.
The filtered index helps locate unprocessed messages without indexing every processed row in the same way.
This schema is illustrative. A production implementation may need fields for the aggregate ID, tenant ID, correlation ID, schema version, lease information, or other operational metadata.
Step 2: Define the Entity Framework Core Entity
Create a C# entity that represents an outbox message.
public sealed class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTime OccurredOnUtc { get; set; }
public DateTime? ProcessedOnUtc { get; set; }
public int RetryCount { get; set; }
public string? Error { get; set; }
}Next, register the entity in your application's DbContext.
public sealed class AppDbContext : DbContext
{
public AppDbContext(
DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<OutboxMessage> OutboxMessages =>
Set<OutboxMessage>();
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<OutboxMessage>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Type)
.HasMaxLength(300)
.IsRequired();
entity.Property(x => x.Payload)
.IsRequired();
entity.Property(x => x.Error)
.HasMaxLength(2000);
entity.HasIndex(x => x.OccurredOnUtc)
.HasFilter("[ProcessedOnUtc] IS NULL");
});
}
}The filter shown here is specific to SQL Server. If you use PostgreSQL or another database provider, configure the filtered or partial index using the syntax supported by that provider.
Create and apply an EF Core migration so the table and index exist in the database.
Step 3: Save the Order and Outbox Event in One Transaction
Now modify the order creation logic so that both the business entity and the event are saved through the same database transaction.
First, define the event:
public sealed record OrderCreated(
Guid OrderId,
decimal TotalAmount);The event contains the information another service needs to react to the new order. Avoid including sensitive customer information unless the receiving system genuinely needs it.
Next, implement the application service:
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public sealed class OrderService
{
private readonly AppDbContext _dbContext;
public OrderService(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Guid> CreateOrderAsync(
decimal totalAmount,
CancellationToken cancellationToken)
{
var order = new Order
{
Id = Guid.NewGuid(),
TotalAmount = totalAmount,
CreatedOnUtc = DateTime.UtcNow
};
var orderCreated = new OrderCreated(
order.Id,
order.TotalAmount);
var outboxMessage = new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(OrderCreated),
Payload = JsonSerializer.Serialize(
orderCreated),
OccurredOnUtc = DateTime.UtcNow
};
await using var transaction =
await _dbContext.Database.BeginTransactionAsync(
cancellationToken);
_dbContext.Orders.Add(order);
_dbContext.OutboxMessages.Add(outboxMessage);
await _dbContext.SaveChangesAsync(
cancellationToken);
await transaction.CommitAsync(
cancellationToken);
return order.Id;
}
}The Order class in this example represents your application's existing order entity.
Both entities are saved within the same transaction. If saving either entity fails, the transaction can be rolled back, preventing the order from being committed without its corresponding outbox message.
If the transaction commits successfully, the outbox event is durable even when the broker is unavailable.
Why Not Publish the Event Inside This Transaction?
A database transaction cannot normally include an ordinary Kafka or RabbitMQ publish operation as part of the same atomic commit.
Publishing while holding the database transaction open also increases transaction duration and may hold locks while the broker is slow or unavailable.
The outbox pattern separates these operations: commit the business state and event record first, then publish the event asynchronously.
Step 4: Define a Message Publisher
The background worker needs an abstraction for publishing messages to your broker.
For example:
public interface IMessagePublisher
{
Task PublishAsync(
string messageId,
string eventType,
string payload,
CancellationToken cancellationToken);
}The implementation can use Kafka, RabbitMQ, Azure Service Bus, or another messaging system.
The messageId is important. Pass the outbox ID through to the broker as a message identifier or header when the chosen broker supports it. Consumers can then use that identifier for deduplication.
The interface does not guarantee delivery by itself. The implementation must await the broker's appropriate acknowledgement and surface publication failures to the caller.
Join the conversation! Your thoughts help the community grow.