DEV Community

Spyros Ponaris
Spyros Ponaris

Posted on

🧩 Reliable Messaging in .NET: Domain Events and the Outbox Pattern with EF Core Interceptors

🧠 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:

  1. Tracks your entity changes.
  2. Generates SQL commands.
  3. 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

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));
Enter fullscreen mode Exit fullscreen mode

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.

  1. When your aggregate raises a domain event, it’s captured and saved into the Outbox table within the same transaction as your entity.

  2. 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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

βš™οΈ 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

🧩 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

  1. Microsoft Docs – EF Core Interceptors
  2. Martin Fowler – Transactional Outbox Pattern
  3. Udi Dahan – Reliable Messaging without Distributed Transactions
  4. Microsoft Docs – BackgroundService in .NET
  5. Jimmy Bogard – Domain Events and the Outbox Pattern in .NET
  6. 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. πŸš€

Top comments (0)