Domain Events, MediatR, and the Outbox Pattern in .NET Microservices.

Patrick KearnsPatrick Kearns
4 min read

When trying to build resilient and scalable microservices, I’ve often found myself grappling with the problem of ensuring consistency between in memory domain events and external communication such as messaging or database writes. In early attempts, I made the mistake of simply publishing messages to an event bus directly inside my application service methods. It worked (until it didn’t). Failures between database writes and message publication would leave the system in an inconsistent state. That’s when I discovered the power of combining Domain Events, MediatR, and the Outbox Pattern. In this post, I’ll walk through how I’ve successfully implemented this pattern in .NET with Cosmos DB and Azure Service Bus.

The Problem: Consistency Between Actions and Notifications

Imagine you have an Order microservice. When an order is placed, you save the order to the database and send an event to an external system (maybe a billing service or notification service). Without careful handling, if the message fails to send after the database write, you’re left with a committed order that no other service knows about. Or, if the database write fails after sending the message, you have a ghost event with no corresponding order.

Step 1: Domain Events with MediatR

The first step is to keep our business logic pure and isolated. We use Domain Events to capture something that happened inside the system. I typically use MediatR to dispatch these events.

Here’s a simple example:

public class OrderCreatedDomainEvent : INotification
{
    public Guid OrderId { get; }

    public OrderCreatedDomainEvent(Guid orderId)
    {
        OrderId = orderId;
    }
}

In the domain layer, we raise this event when the aggregate’s state changes:

public class Order : AggregateRoot
{
    public void PlaceOrder()
    {
        // Business logic here

        AddDomainEvent(new OrderCreatedDomainEvent(Id));
    }
}

Step 2: Implementing the Outbox Pattern

Instead of sending the event immediately to Azure Service Bus, we store it as an "outbox message" in the same transaction as the aggregate update. This ensures atomicity. With Cosmos DB, this typically means storing an additional document in the same container or an adjacent one, depending on partitioning requirements.

Here’s an example of the Outbox entity:

public class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; }
    public string Content { get; set; }
    public DateTime OccurredOn { get; set; }
    public bool Processed { get; set; }
}

When saving the Order, you also save the OutboxMessage inside the same Unit of Work:

var order = new Order();
order.PlaceOrder();

var domainEvent = new OrderCreatedDomainEvent(order.Id);
var outboxMessage = new OutboxMessage
{
    Id = Guid.NewGuid(),
    Type = domainEvent.GetType().FullName,
    Content = JsonSerializer.Serialize(domainEvent),
    OccurredOn = DateTime.UtcNow,
    Processed = false
};

await dbContext.Orders.AddAsync(order);
await dbContext.OutboxMessages.AddAsync(outboxMessage);
await dbContext.SaveChangesAsync();

Step 3: Outbox Processor: Bridging to Azure Service Bus

Now that we have reliably stored the message, a background worker (hosted service) periodically checks for unprocessed outbox messages and publishes them to Azure Service Bus. Only after successful delivery is the OutboxMessage marked as processed.

public class OutboxProcessor : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<OutboxProcessor> _logger;

    public OutboxProcessor(IServiceProvider serviceProvider, ILogger<OutboxProcessor> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _serviceProvider.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var serviceBusSender = scope.ServiceProvider.GetRequiredService<IServiceBusSender>();

            var messages = await dbContext.OutboxMessages
                .Where(m => !m.Processed)
                .ToListAsync(stoppingToken);

            foreach (var message in messages)
            {
                var eventPayload = message.Content;
                await serviceBusSender.SendAsync(eventPayload, message.Type);

                message.Processed = true;
            }

            await dbContext.SaveChangesAsync(stoppingToken);
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

Step 4: Consuming the Event in Downstream Services

Once the event is published to Azure Service Bus, any subscribed microservice can consume and react to it. For example, a Billing microservice could handle OrderCreated events to initiate payment collection.

public class OrderCreatedHandler : IOrderCreatedHandler
{
    public Task HandleAsync(OrderCreatedDomainEvent domainEvent)
    {
        // Trigger billing process
        return Task.CompletedTask;
    }
}

Lessons Learned

In one system where we applied this pattern, we reduced critical inconsistencies by over 95% within the first two weeks. Before this change, our error logs were littered with cases where external services acted on events for database rows that never existed due to transactional failures. Using the Outbox Pattern brought immediate clarity and reliability.

We also learned the importance of idempotency in downstream consumers. Since outbox processors can retry messages, it’s vital that consumers handle duplicate events gracefully.

By combining Domain Events, MediatR, and the Outbox Pattern, we can build microservices that are both reliable and scalable. gRPC, REST, or messaging systems like Azure Service Bus can all benefit from this level of consistency. In my own projects, this approach has become the standard way of ensuring that business events are communicated safely and predictably.

If you’re building distributed systems in .NET, I would recommend giving this pattern a try. The upfront investment in structure pays off in stability, especially as systems grow more complex.

1
Subscribe to my newsletter

Read articles from Patrick Kearns directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Patrick Kearns
Patrick Kearns