Event-Driven Communication Between Modules in .NET

Event-Driven Communication Between Modules in .NET

8 min read··

distributed-systemsdotnetmodular-monolith

Event-driven communication in a modular monolith means one module publishes an integration event and other modules react to it independently, without the publisher knowing they exist. An in-process event bus is enough to start, and the outbox pattern adds reliability when a consumer fails or the process crashes. Here is the full setup in .NET.

The Ordering module saves an order, and the Shipping module needs to know about it. The obvious solution (Ordering calls Shipping directly) couples the two modules forever. The event-driven alternative inverts the relationship: Ordering announces what happened, and any module that cares reacts on its own terms.

Why Event-Driven?

In a Modular Monolith, modules must stay decoupled. Direct method calls between modules create tight coupling - exactly what you're trying to avoid.

Event-driven communication solves this: one module publishes an event, and other modules react independently. The publisher doesn't know (or care) who's listening.

Domain Events vs Integration Events

There are two types of events in a Modular Monolith:

  • Domain events stay within a module and are handled in the same transaction. Example: OrderPlaced, consumed by another handler inside the Ordering module.
  • Integration events cross module boundaries and are processed in separate transactions. Example: OrderPlacedIntegrationEvent, consumed by the Shipping and Notification modules.

Domain events are internal to a module. Integration events cross module boundaries. A common flow: a domain event handler inside the module maps the domain event to an integration event and publishes it. That way, module internals (entity IDs, domain types) never leak into the shared contract.

Setting Up Integration Events

Define a shared contract that modules can reference:

// Shared contracts assembly
public interface IIntegrationEvent : INotification
{
    Guid EventId { get; }
    DateTime OccurredOnUtc { get; }
}

public sealed record OrderPlacedIntegrationEvent(
    Guid EventId,
    DateTime OccurredOnUtc,
    Guid OrderId,
    Guid CustomerId,
    decimal TotalAmount) : IIntegrationEvent;

public sealed record PaymentCompletedIntegrationEvent(
    Guid EventId,
    DateTime OccurredOnUtc,
    Guid OrderId,
    decimal Amount) : IIntegrationEvent;

These live in a contracts project that both modules can reference - no dependency on internal module code.

One deliberate choice to call out: this IIntegrationEvent marker extends MediatR's INotification, because I'm about to use MediatR as the in-process event bus. MediatR throws if you publish an object that doesn't implement INotification, so this is not optional. The cost is a reference to the small MediatR.Contracts package from the contracts project. The shared kernel article shows the stricter, dependency-free variant of the same contract, with its own IIntegrationEventHandler<T> abstraction instead of MediatR's handler interface.

In-Process Event Bus

For a Modular Monolith running in a single process, you can use MediatR notifications as an event bus. You can also build a lightweight message bus with .NET Channels if you want true fire-and-forget semantics. The abstraction is what matters:

public interface IEventBus
{
    Task PublishAsync<T>(T integrationEvent, CancellationToken ct = default)
        where T : IIntegrationEvent;
}

public class InProcessEventBus : IEventBus
{
    private readonly IPublisher _publisher;

    public InProcessEventBus(IPublisher publisher)
    {
        _publisher = publisher;
    }

    public async Task PublishAsync<T>(
        T integrationEvent, CancellationToken ct) where T : IIntegrationEvent
    {
        await _publisher.Publish(integrationEvent, ct);
    }
}

Register it:

services.AddScoped<IEventBus, InProcessEventBus>();

Publishing Events

The Ordering module publishes an event after placing an order:

// Ordering module
public sealed class PlaceOrderHandler
    : IRequestHandler<PlaceOrderCommand, Result<Guid>>
{
    private readonly OrderingDbContext _db;
    private readonly IEventBus _eventBus;

    public PlaceOrderHandler(
        OrderingDbContext db, IEventBus eventBus)
    {
        _db = db;
        _eventBus = eventBus;
    }

    public async Task<Result<Guid>> Handle(
        PlaceOrderCommand command, CancellationToken ct)
    {
        var order = Order.Create(command.CustomerId, command.Items);

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);

        // Publish integration event
        await _eventBus.PublishAsync(
            new OrderPlacedIntegrationEvent(
                Guid.NewGuid(),
                DateTime.UtcNow,
                order.Id,
                order.CustomerId,
                order.TotalAmount),
            ct);

        return Result.Success(order.Id);
    }
}

Consuming Events

Other modules subscribe to events independently:

// Shipping module
public sealed class OrderPlacedHandler
    : INotificationHandler<OrderPlacedIntegrationEvent>
{
    private readonly ShippingDbContext _db;

    public OrderPlacedHandler(ShippingDbContext db) => _db = db;

    public async Task Handle(
        OrderPlacedIntegrationEvent notification, CancellationToken ct)
    {
        var shipment = Shipment.CreateFor(
            notification.OrderId,
            notification.CustomerId);

        _db.Shipments.Add(shipment);
        await _db.SaveChangesAsync(ct);
    }
}

// Notification module
public sealed class SendOrderConfirmationHandler
    : INotificationHandler<OrderPlacedIntegrationEvent>
{
    private readonly IEmailService _emailService;

    public SendOrderConfirmationHandler(IEmailService emailService) =>
        _emailService = emailService;

    public async Task Handle(
        OrderPlacedIntegrationEvent notification, CancellationToken ct)
    {
        await _emailService.SendOrderConfirmationAsync(
            notification.CustomerId,
            notification.OrderId,
            ct);
    }
}

The Ordering module doesn't know about Shipping or Notifications. Each module independently decides how to react.

One gotcha you should know: MediatR's Publish awaits every handler synchronously, in the same request scope. If the Shipping handler takes two seconds, the user placing the order waits those two seconds. If the Notification handler throws, the exception propagates back to the publisher. "Event-driven" here means decoupled in code, not decoupled at runtime.

That's exactly the problem the outbox solves.

The Outbox Pattern for Reliability

What if the event consumer fails? With in-process events, you risk inconsistency - the order is saved but the shipment isn't created.

The Outbox Pattern solves this. The outbox message is a plain entity in the Ordering module's schema, with a few extra fields for retry bookkeeping that we'll use later:

public sealed class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public DateTime OccurredOnUtc { get; set; }
    public DateTime? ProcessedOnUtc { get; set; }
    public int RetryCount { get; set; }
    public string? Error { get; set; }
    public DateTime? FailedOnUtc { get; set; }
}

The handler saves the order and the outbox message in the same transaction:

public sealed class PlaceOrderHandler
    : IRequestHandler<PlaceOrderCommand, Result<Guid>>
{
    private readonly OrderingDbContext _db;

    public PlaceOrderHandler(OrderingDbContext db) => _db = db;

    public async Task<Result<Guid>> Handle(
        PlaceOrderCommand command, CancellationToken ct)
    {
        var order = Order.Create(command.CustomerId, command.Items);

        var orderPlaced = new OrderPlacedIntegrationEvent(
            Guid.NewGuid(), DateTime.UtcNow,
            order.Id, order.CustomerId, order.TotalAmount);

        // Save order AND outbox message in the same transaction
        _db.Orders.Add(order);
        _db.OutboxMessages.Add(new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = orderPlaced.GetType().FullName!,
            Content = JsonSerializer.Serialize(orderPlaced),
            OccurredOnUtc = orderPlaced.OccurredOnUtc
        });

        await _db.SaveChangesAsync(ct);

        return Result.Success(order.Id);
    }
}

Note that Type stores the full type name. The processor needs it to resolve the CLR type when deserializing, and a bare class name won't resolve across assemblies.

A background job processes outbox messages and publishes them:

public class OutboxProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OutboxProcessor> _logger;

    public OutboxProcessor(
        IServiceScopeFactory scopeFactory,
        ILogger<OutboxProcessor> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider
                .GetRequiredService<OrderingDbContext>();
            var eventBus = scope.ServiceProvider
                .GetRequiredService<IEventBus>();

            var messages = await db.OutboxMessages
                .Where(m => m.ProcessedOnUtc == null)
                .OrderBy(m => m.OccurredOnUtc)
                .Take(20)
                .ToListAsync(ct);

            foreach (var message in messages)
            {
                var @event = DeserializeEvent(
                    message.Type, message.Content);

                await eventBus.PublishAsync(@event, ct);

                message.ProcessedOnUtc = DateTime.UtcNow;
            }

            await db.SaveChangesAsync(ct);
            await Task.Delay(TimeSpan.FromSeconds(5), ct);
        }
    }

    private static IIntegrationEvent DeserializeEvent(
        string type, string content)
    {
        var eventType =
            typeof(OrderPlacedIntegrationEvent).Assembly.GetType(type)
            ?? throw new InvalidOperationException(
                $"Unknown event type: {type}");

        return (IIntegrationEvent)JsonSerializer
            .Deserialize(content, eventType)!;
    }
}

Register it as a hosted service:

services.AddHostedService<OutboxProcessor>();

Two details in this class do a lot of work.

First, the processor is a singleton (every BackgroundService is), while the DbContext and event bus are scoped. Constructor-injecting them would throw at startup with scope validation enabled, which is why the processor creates a scope per iteration through IServiceScopeFactory. This is the same rule I covered in DI lifetimes.

Second, DeserializeEvent resolves the type from the contracts assembly. Type.GetType with a bare type name only searches the calling assembly and the core library, so it would return null here and fail at runtime.

Event Ordering

With a single outbox processor reading messages ordered by OccurredOnUtc, events are published in the order they were saved. That's one of the underrated benefits of the in-process setup: you get ordering for free, without partitions or sequence numbers.

Ordering only becomes a real problem when you scale out to multiple processors or move to a message broker. At that point you need partition keys or per-aggregate sequencing - I cover the options in message ordering in distributed systems.

Error Handling

When a consumer fails:

foreach (var message in messages)
{
    try
    {
        var @event = DeserializeEvent(message.Type, message.Content);
        await eventBus.PublishAsync(@event, ct);
        message.ProcessedOnUtc = DateTime.UtcNow;
    }
    catch (Exception ex)
    {
        message.RetryCount++;
        message.Error = ex.Message;

        if (message.RetryCount >= 3)
        {
            message.ProcessedOnUtc = DateTime.UtcNow;
            message.FailedOnUtc = DateTime.UtcNow;
            _logger.LogError(ex,
                "Outbox message {Id} failed after {Retries} retries",
                message.Id, message.RetryCount);
        }
    }
}

Failed messages after max retries go to a dead letter state for manual investigation.

There's a flip side to retries: a consumer can receive the same event twice. If the processor publishes a message and crashes before marking it processed, the next run publishes it again. Consumers must be idempotent - the idempotent consumer pattern covers how to handle duplicates safely. This is exactly why every integration event carries an EventId: it's the natural deduplication key.

Module Boundaries

Events enforce boundaries:

Flow diagram of the Orders module saving an order and outbox message in one transaction, a background outbox processor publishing OrderPlacedIntegrationEvent, and the Shipping and Notification modules each reacting independently

Modules never reference each other's internals. They communicate exclusively through events defined in shared contracts.

Summary

Event-driven communication in a Modular Monolith:

  1. Integration events cross module boundaries, domain events stay internal
  2. IEventBus abstracts the publishing mechanism (in-process or message broker)
  3. Outbox Pattern guarantees events are published even if the consumer fails
  4. Shared contracts define events without coupling module internals
  5. Background processor publishes outbox messages with retry logic

Start with in-process events. Add the Outbox Pattern when you need reliability. When you eventually extract to microservices, swap the in-process bus for RabbitMQ or Azure Service Bus.

Thanks for reading, and stay awesome!


Frequently Asked Questions

How do modules communicate in a modular monolith?

Modules communicate either synchronously through public interfaces or asynchronously through integration events. Events keep modules decoupled because the publisher does not know which modules consume the event.

What is the difference between domain events and integration events?

Domain events stay inside a single module and are usually handled within the same transaction. Integration events cross module boundaries, live in a shared contracts project, and are processed in separate transactions.

Do you need a message broker in a modular monolith?

No. Since all modules run in one process, an in-process event bus (for example MediatR notifications or a channel-based bus) is enough. You only add a broker like RabbitMQ when you extract a module into a separate service.

Why use the outbox pattern in a modular monolith?

The outbox pattern saves the event in the same database transaction as the business data, and a background job publishes it afterwards. This guarantees the event is not lost if the process crashes or a consumer fails.

Are MediatR notification handlers executed asynchronously?

No. MediatR Publish awaits every notification handler in the same process and request scope. If you need true asynchronous processing, persist the event with the outbox pattern and let a background worker publish it.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.