Modern applications often need to update a database and publish an event to a message broker. For example, an e-commerce application may save an order to SQL Server and then publish an OrderCreated event to RabbitMQ, Azure Service Bus, or Kafka.
The challenge is ensuring both operations succeed together. If the database transaction commits but event publishing fails, downstream systems never receive the event. Conversely, if the event is published but the database transaction fails, consumers receive an event for data that doesn't exist.
The Outbox Pattern solves this problem by storing events in the same database transaction as the business data and publishing them asynchronously later.
In this article, you'll build a production-ready Outbox implementation in ASP.NET Core, understand how it works, and learn best practices for reliable event publishing.
Note: This article focuses on architecture and implementation. Performance depends on workload, database design, polling intervals, and messaging infrastructure.
What Is the Outbox Pattern?
Instead of publishing an event directly after saving data, the application stores the event in an Outbox table within the same database transaction.
A background service later reads pending events and publishes them to the message broker.
Client Request
│
▼
Business Logic
│
▼
Database Transaction
│
┌────┴─────────┐
▼ ▼
Business Data Outbox Event
│
▼
Transaction Commit
│
▼
Background Publisher
│
▼
Message Broker
This guarantees that if the business transaction succeeds, the event is never lost.
Why Use the Outbox Pattern?
Without the Outbox Pattern:
Database commit succeeds.
Message broker becomes unavailable.
Event is lost.
Downstream services never process the operation.
With the Outbox Pattern:
Business data and event are committed together.
Event publishing can be retried safely.
Temporary messaging failures don't lose events.
Create the Outbox Entity
Create an entity to store pending events.
public class OutboxMessage
{
public Guid Id { get; set; }
public string EventType { get; set; } = "";
public string Payload { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
}
Each row represents one event waiting to be published.
Configure EF Core
Register the Outbox table.
public class AppDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OutboxMessage> Outbox =>
Set<OutboxMessage>();
public AppDbContext(
DbContextOptions<AppDbContext> options)
: base(options)
{
}
}
Run the migration to create the table.
dotnet ef migrations add AddOutbox
dotnet ef database update
Save Business Data and Event Together
Suppose an order is created.
public async Task CreateOrderAsync(Order order)
{
_context.Orders.Add(order);
var message = new OutboxMessage
{
Id = Guid.NewGuid(),
EventType = "OrderCreated",
Payload = JsonSerializer.Serialize(order),
CreatedAt = DateTime.UtcNow
};
_context.Outbox.Add(message);
await _context.SaveChangesAsync();
}
Both the order and the Outbox record are committed in a single database transaction.
Build the Background Publisher
Create a hosted service that processes pending events.
public class OutboxProcessor
: BackgroundService
{
private readonly IServiceProvider _provider;
public OutboxProcessor(
IServiceProvider provider)
{
_provider = provider;
}
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope =
_provider.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
var events = await db.Outbox
.Where(x => x.ProcessedAt == null)
.ToListAsync(stoppingToken);
foreach (var message in events)
{
// Publish to broker
message.ProcessedAt =
DateTime.UtcNow;
}
await db.SaveChangesAsync(
stoppingToken);
await Task.Delay(
TimeSpan.FromSeconds(5),
stoppingToken);
}
}
}
The service periodically publishes pending events and marks them as processed.
Register the Hosted Service
builder.Services.AddHostedService<
OutboxProcessor>();
The publisher starts automatically with the application.
Publishing to a Message Broker
The publishing logic depends on the messaging platform.
await publisher.PublishAsync(
message.EventType,
message.Payload);
The Outbox Pattern works with:
RabbitMQ
Azure Service Bus
Apache Kafka
Amazon SQS
NATS
Redis Streams
The persistence strategy remains the same regardless of the broker.
Handling Failures
If publishing fails:
Keep the Outbox row unchanged.
Retry during the next polling cycle.
Mark the event as processed only after successful publishing.
Avoid deleting events before confirming successful delivery.
Prevent Duplicate Processing
A consumer may receive the same event more than once if publishing succeeds but the application crashes before updating ProcessedAt.
Consumers should therefore be idempotent.
Example strategies include:
Event IDs
Message deduplication
Processed message table
Unique business keys
Idempotency is a critical part of any reliable messaging architecture.
End-to-End Workflow
A complete request follows these steps:
Client submits an order.
Order is saved.
Outbox event is stored.
Database transaction commits.
Background service reads pending events.
Event is published.
Event is marked as processed.
Consumers process the event.
This ensures reliable event delivery even during temporary messaging outages.
Outbox Pattern vs Direct Publishing
| Feature | Direct Publish | Outbox Pattern |
|---|
| Atomic database update | No | Yes |
| Reliable delivery | Limited | Yes |
| Retry support | Manual | Built-in |
| Event persistence | No | Yes |
| Temporary broker outage handling | Poor | Excellent |
| Suitable for microservices | Moderate | Excellent |
Performance Evaluation Methodology
The research brief references reliability and scalability but does not include benchmark results. Instead of presenting unsupported figures, evaluate your implementation using the following methodology.
Test Environment
Keep these variables consistent:
.NET SDK version
Database engine
Message broker
Hardware
Network conditions
Polling interval
Test Scenarios
Compare:
Metrics to Measure
Collect:
Useful Tools
Useful tools include:
Validate behavior under production-like failure conditions rather than relying only on successful execution paths.
Best Practices
Store Outbox records in the same transaction as business data.
Keep event payloads immutable.
Include unique event identifiers.
Build idempotent consumers.
Monitor Outbox table growth.
Archive or remove processed events periodically.
Implement retry with exponential backoff if appropriate.
Log publishing failures with sufficient context.
Common Mistakes
| Mistake | Impact |
|---|
| Publishing before committing the database | Lost consistency |
| Deleting events before successful publish | Event loss |
| No retry mechanism | Failed events remain unpublished |
| Non-idempotent consumers | Duplicate processing |
| Never cleaning the Outbox table | Unbounded database growth |
| Long polling intervals | Increased event latency |
Troubleshooting
Events Remain in the Outbox Table
Verify:
Background service is running.
Message broker is available.
Publishing logic is not throwing exceptions.
Database updates are being committed.
Duplicate Events
Review:
Outbox Table Grows Continuously
Check:
Cleanup strategy.
Processing failures.
Polling frequency.
Broker health.
Implement scheduled archival or deletion of successfully processed events.
FAQs
Why not publish directly after SaveChanges()?
If publishing fails after the database transaction succeeds, the event is permanently lost. The Outbox Pattern eliminates this inconsistency.
Does the Outbox Pattern guarantee exactly-once delivery?
No. It typically guarantees at-least-once delivery. Consumers should be designed to handle duplicate events safely.
Can the Outbox Pattern work with any message broker?
Yes. It is independent of the messaging platform and can be used with RabbitMQ, Azure Service Bus, Kafka, and other brokers.
How often should the background service poll the Outbox table?
The interval depends on latency requirements and workload. Shorter intervals reduce event latency but increase database activity.
Should processed Outbox records be deleted?
Yes. Archive or delete processed records periodically to prevent the Outbox table from growing indefinitely.
Conclusion
The Outbox Pattern is a proven solution for reliable event publishing in distributed systems. By persisting business data and integration events within the same database transaction, it eliminates the risk of losing events due to temporary messaging failures.
Combined with background processing, retries, idempotent consumers, and regular cleanup, the Outbox Pattern provides a robust foundation for event-driven ASP.NET Core applications and microservice architectures.