Introduction
Distributed systems are powerful — but they come with one big challenge: reliability.
What happens if your application updates a record in the database and then tries to publish a message to a message broker… but crashes in between?
You’ve just lost an event.
This problem is common in microservices, and it’s why the Outbox Pattern exists — a simple, elegant approach that guarantees message delivery without introducing distributed transactions or external coordinators.
In this article, we’ll implement a Domain Event + Outbox Pattern in .NET 9, using Entity Framework Core, SQLite, and an EF Core SaveChangesInterceptor.
⚙️ A Quick Look at Entity Framework Core
Entity Framework Core (EF Core) is Microsoft’s modern Object-Relational Mapper (ORM) for .NET.
It allows developers to work with a database using C# classes instead of raw SQL, automatically handling relationships, migrations, and change tracking.
When you call SaveChanges() or SaveChangesAsync(), EF Core:
- Tracks your entity changes.
- Generates SQL commands.
- Executes them in a transaction.
This makes EF Core the perfect place to integrate the Outbox Pattern, since we can hook into its save pipeline and ensure domain events are written in the same transaction as the data itself.
What Are Interceptors?
In EF Core, interceptors are powerful hooks that allow you to intercept and extend the framework’s internal operations — such as saving, querying, or connecting to the database.
They work like middleware for EF Core:
you can observe, modify, or add logic before and after EF performs certain actions.
There are several types:
SaveChangesInterceptor
CommandInterceptor
ConnectionInterceptor
TransactionInterceptor
In this article, we’ll use SaveChangesInterceptor to detect domain events before EF saves, serialize them, and insert them into our Outbox table.
The Problem
In traditional architectures, your service might do something like this:
await _db.SaveChangesAsync();
await _bus.PublishAsync(new OrderPlacedEvent(orderId));
If your app crashes after saving but before publishing, your event never reaches other services — resulting in inconsistent state across systems.
We need a way to ensure atomicity: either both the data and the event are persisted, or neither are.
The Outbox Pattern (In a Nutshell)
The Outbox Pattern solves this by introducing a dedicated Outbox table inside your database.
- When your aggregate raises a domain event, it’s captured and saved into the Outbox table within the same transaction as your entity.
- A background worker (or a separate service) polls the Outbox table, publishes events, and marks them as processed.
This way, even if your app crashes, the events are safely stored and can be retried later.
The Architecture
┌──────────────────────────────┐
│ Application │
│ ──────────────────────────── │
│ Order raises DomainEvent │
│ → EF Interceptor writes │
│ Outbox row │
│ → Transaction commits │
└──────────────┬───────────────┘
│
▼
Outbox Dispatcher
├─ Polls database
├─ Publishes messages
└─ Marks them processed
This pattern elegantly blends Domain-Driven Design concepts with event-driven reliability.
Domain Events
Domain events capture something meaningful that happened in your domain model.
For example:
public sealed record OrderPlacedDomainEvent(Guid OrderId, decimal Total)
: DomainEvent(DateTime.UtcNow);
And your aggregate raises them naturally:
public sealed class Order : IHasDomainEvents
{
private readonly List<DomainEvent> _domainEvents = new();
public Guid Id { get; private set; } = Guid.NewGuid();
public decimal Total { get; private set; }
public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents;
public Order(decimal total)
{
Total = total;
AddEvent(new OrderPlacedDomainEvent(Id, Total));
}
private void AddEvent(DomainEvent @event) => _domainEvents.Add(@event);
public void ClearDomainEvents() => _domainEvents.Clear();
}
⚙️ Capturing Events with an EF Core Interceptor
Instead of overriding SaveChangesAsync() directly inside AppDbContext, we can use a SaveChangesInterceptor — a clean, composable way to plug custom logic into EF’s lifecycle.
public sealed class OutboxSaveChangesInterceptor(IEventSerializer serializer) : SaveChangesInterceptor
{
/// <summary>
///
/// </summary>
/// <param name="eventData"></param>
/// <param name="result"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context;
if (context is null)
return await base.SavingChangesAsync(eventData, result, cancellationToken);
var aggregates = context.ChangeTracker
.Entries()
.Where(e => e.Entity is IHasDomainEvents hasEvents && hasEvents.DomainEvents.Count > 0)
.Select(e => (IHasDomainEvents)e.Entity)
.ToList();
if (aggregates.Count == 0)
return await base.SavingChangesAsync(eventData, result, cancellationToken);
var outbox = context.Set<OutboxMessage>();
foreach (var aggregate in aggregates)
{
foreach (var @event in aggregate.DomainEvents)
{
outbox.Add(new OutboxMessage
{
OccurredOnUtc = @event.OccurredOnUtc,
Type = @event.GetType().AssemblyQualifiedName!,
Payload = serializer.Serialize(@event)
});
}
}
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
Now every time you call SaveChangesAsync(), domain events are written to your OutboxMessages table automatically — in the same transaction.
The Outbox Dispatcher
Once the transaction is committed, a background worker can safely process these messages.
public sealed class OutboxDispatcher(IServiceProvider sp,
ILogger<OutboxDispatcher> logger) : BackgroundService
{
private readonly IServiceProvider _sp = sp;
private readonly ILogger<OutboxDispatcher> _logger = logger;
private readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
private const int BatchSize = 50;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(" Outbox Dispatcher started...");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var jsonSerializer = scope.ServiceProvider.GetRequiredService<IEventSerializer>();
var pending = await db.OutboxMessages
.Where(x => x.ProcessedOnUtc == null)
.OrderBy(x => x.Id)
.Take(BatchSize)
.ToListAsync(stoppingToken);
foreach (var msg in pending)
{
try
{
var evt = jsonSerializer.Deserialize(msg.Payload, msg.Type);
await SimulatedBusPublish(evt, stoppingToken);
msg.ProcessedOnUtc = DateTime.UtcNow;
msg.Error = null;
}
catch (Exception ex)
{
msg.Error = ex.Message;
_logger.LogError(ex, "❌ Error dispatching OutboxMessage {Id}", msg.Id);
}
}
if (pending.Count > 0)
await db.SaveChangesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Outbox dispatcher loop error");
}
await Task.Delay(_pollInterval, stoppingToken);
}
}
private Task SimulatedBusPublish(DomainEvent evt, CancellationToken ct)
=> evt switch
{
OrderPlacedDomainEvent e => HandleOrderPlaced(e),
_ => Task.CompletedTask
};
private Task HandleOrderPlaced(OrderPlacedDomainEvent e)
{
Console.WriteLine($"[BUS] ✅ OrderPlaced published => OrderId={e.OrderId}, Total={e.Total}");
return Task.CompletedTask;
}
}
Why Use an Interceptor?
Using an interceptor instead of overriding SaveChangesAsync() has several advantages:
| Approach | Pros | Cons |
| Override SaveChangesAsync | Simple to start | Harder to reuse across contexts |
| Use Interceptor | Decoupled, reusable, testable | Slightly more setup |
Interceptors follow the Open/Closed Principle — your DbContext remains focused on persistence, while cross-cutting logic (like event logging, auditing, or outbox handling) is easily attachable.
Benefits of the Outbox Pattern
- ✅ Transactional Safety — data + events saved atomically
- ✅ Guaranteed Delivery — retries handled by dispatcher
- ✅ DDD Alignment — aggregates raise domain events naturally
- ✅ Resilience — works even if message broker is offline
- ✅ Extensibility — can integrate with RabbitMQ, Kafka, or Azure Service Bus later
References
- Microsoft Docs – EF Core Interceptors
- Martin Fowler – Transactional Outbox Pattern
- Udi Dahan – Reliable Messaging without Distributed Transactions
- Microsoft Docs – BackgroundService in .NET
- Jimmy Bogard – Domain Events and the Outbox Pattern in .NET
- Source Code
✨ Closing Thoughts
The Domain Events + Outbox Pattern is one of the most practical ways to achieve reliable, eventually consistent event-driven systems in .NET.
By combining Entity Framework Core, SaveChangesInterceptor, and a simple background dispatcher, you can ensure every event is persisted and delivered — even in the face of failure.
Small pattern. Big reliability.
Author
Spyros Ponaris
LinkedIn
Senior Software Engineer & .NET Enthusiast