# Event-Driven Communication Between Modules in .NET

> The Ordering module publishes an event, and Shipping and Notifications react without the publisher knowing they exist. The catch: an in-process event bus runs every handler synchronously in the caller's scope, so a slow or failing consumer becomes the publisher's problem. Here is the full setup, and the point where the outbox pattern has to take over.

Published: 2026-08-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/event-driven-communication-modules

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**](https://milanjovanovic.tech/blog/modular-monolith-architecture-dotnet), 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**](https://milanjovanovic.tech/blog/modular-monolith-communication-patterns):

- **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**](https://milanjovanovic.tech/blog/domain-events-vs-integration-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:

```csharp
// 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**](https://milanjovanovic.tech/blog/shared-kernel-pattern-modular-monolith) 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**](https://milanjovanovic.tech/blog/lightweight-in-memory-message-bus-using-dotnet-channels) if you want true fire-and-forget semantics.
The abstraction is what matters:

```csharp
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:

```csharp
services.AddScoped<IEventBus, InProcessEventBus>();
```

## Publishing Events

The Ordering module publishes an event after placing an order:

```csharp
// 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:

```csharp
// 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**](https://milanjovanovic.tech/blog/outbox-pattern-dual-write-problem) 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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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**](https://milanjovanovic.tech/blog/idempotent-consumer-handling-duplicate-messages) 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](https://milanjovanovic.tech/blogs/articles/event-driven-communication-modules/integration-event-flow.png)

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**](https://milanjovanovic.tech/blog/when-to-extract-module-to-microservice), 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.
