π§ 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.
π Learn more about EF Core Interceptors
- Mastering EF Core Interceptors: Hook into the pipeline with the Decorator Pattern β by stevsharp [https://dev.to/stevsharp/mastering-ef-core-interceptors-hook-into-the-pipeline-with-the-decorator-pattern-g05]
A practical deep dive into EF Coreβs interceptor types (SaveChanges, Command, Connection, Transaction), when to use them, and how to compose cross-cutting behaviors cleanly using the Decorator pattern. Great companion read to this Outbox + interceptor implementation.
π‘ 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 : SaveChangesInterceptor
{
private readonly IEventSerializer _serializer;
public OutboxSaveChangesInterceptor(IEventSerializer serializer)
=> _serializer = serializer;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context;
if (context == null) return result;
// Find all entities that raised domain events
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 result;
var outbox = context.Set<OutboxMessage>();
// Serialize each event into the Outbox table
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 result;
}
}
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 : BackgroundService
{
private readonly IServiceProvider _sp;
private readonly ILogger<OutboxDispatcher> _logger;
private readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(2);
public OutboxDispatcher(IServiceProvider sp, ILogger<OutboxDispatcher> logger)
{
_sp = sp;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Outbox dispatcher started...");
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pending = await db.OutboxMessages
.Where(x => x.ProcessedOnUtc == null)
.OrderBy(x => x.Id)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var msg in pending)
{
try
{
var evt = SystemTextJsonEventSerializer.Deserialize(msg.Payload, msg.Type);
await SimulatedPublish(evt);
msg.ProcessedOnUtc = DateTime.UtcNow;
}
catch (Exception ex)
{
msg.Error = ex.Message;
_logger.LogError(ex, "Error processing outbox message {Id}", msg.Id);
}
}
if (pending.Any())
await db.SaveChangesAsync(stoppingToken);
await Task.Delay(_pollInterval, stoppingToken);
}
}
private Task SimulatedPublish(DomainEvent evt)
{
if (evt is OrderPlacedDomainEvent e)
Console.WriteLine($"[BUS] β
OrderPlaced => {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.
Top comments (0)