Asynchronous Communication Between Modules: Integration Events, In-Memory Bus and the Outbox
Modules notify each other with integration events from Contracts; a forty-line library-free in-process bus is enough for that, but publishing the event only in memory loses it when a handler fails or the process crashes. With real SQL Server output we show in-memory publishing losing a Bookstore shipment, and a minimal outbox in the order's transaction plus a per-module inbox reserving stock only once when the same event arrives three times.
Version note: The code in this post was built and run with C# 14, .NET 10, Entity Framework Core (EF Core) 10 and SQL (Structured Query Language) Server 2025.
Guess First
After an order is placed in Bookstore, three modules have work to do: Inventory reserves stock, Payments opens a payment record and waits for the provider, and Shipping creates a shipment record. Instead of calling them one by one, Ordering publishes an OrderPlaced event and a small in-memory bus dispatches it to three handlers.
In Friday evening's deploy someone forgot to add Shipping's Shipping:DefaultCarrier setting to the configuration. A customer places an order for two books. What does the customer see on screen? Which tables get records and which don't? When someone adds the setting on Monday morning and restarts the application, does Shipping create the shipment for that order?
Domain Events and Integration Events Are Not the Same Thing
In the Domain-Driven Design (DDD) series post "Domain Events: Raise, Collect, and Dispatch on SaveChanges" we raised domain events inside an aggregate and dispatched them to the same module's handlers during SaveChanges. Those events live inside the module: they can be internal, they can reference entities, and they can be processed as part of the same transaction.
An integration event, by contrast, is a message that crosses a module boundary, so its rules are different: it sits in the Contracts project as a public record, it must be serializable, it carries only primitive types and its own small records, it has its own identity, and once published it has to stay backwards compatible. Publishing an integration event as a consequence of a domain event is a common flow: Order.Place raises a domain event, and a handler inside Ordering turns it into an OrderPlaced integration event. Message types, the difference between event notification and event-carried state transfer, and schema evolution are the subject of the Messaging and Event-Driven Architecture series; here we focus on the minimal implementation in a module context.
The Event Record in Contracts
The fact Ordering publishes:
using Bookstore.BuildingBlocks.Messaging;
namespace Bookstore.Ordering.Contracts;
public sealed record OrderPlacedLine(Guid BookId, int Quantity);
// The fact Ordering publishes: everything the other modules need is inside
public sealed record OrderPlaced(
Guid EventId, DateTime OccurredAt, Guid OrderId,
IReadOnlyList<OrderPlacedLine> Lines, decimal Total, string Currency) : IIntegrationEvent;
The event carries everything other modules need to do their work: how many copies of which book, the total and the currency. When Inventory receives this event it should not have to ask Ordering "what was in the order?"; otherwise we add a synchronous dependency on top of the asynchronous communication.
The shared interfaces live in Bookstore.BuildingBlocks:
namespace Bookstore.BuildingBlocks.Messaging;
// A fact published between modules: past tense, immutable, with its own identity
public interface IIntegrationEvent
{
Guid EventId { get; }
DateTime OccurredAt { get; }
}
public interface IIntegrationEventHandler<in TEvent> where TEvent : IIntegrationEvent
{
Task HandleAsync(TEvent @event, CancellationToken ct);
}
public interface IEventBus
{
Task PublishAsync(IIntegrationEvent @event, CancellationToken ct);
}
In this post we split BuildingBlocks in two. Bookstore.BuildingBlocks depends on no packages at all; the Contracts projects see only that. The outbox table, the EF Core configuration and the background worker live in Bookstore.BuildingBlocks.Infrastructure, which only the modules' Core projects use. That way a module referencing Ordering.Contracts does not pick up EF Core and the SQL Server driver transitively.
A Library-Free In-Process Bus
The .NET world has ready-made solutions for this, but as of November 2026, when this post was written, the licensing landscape looks different from a few years ago. MediatR moved to Jimmy Bogard's company Lucky Penny Software; from 13.0, released on 2 July 2025, the versions on NuGet are distributed under RPL (Reciprocal Public License) 1.5 or a paid commercial license, and the last Apache 2.0 version is 12.5.0 from April 2025. MassTransit's v9 line is a commercial product through the company Massient; the current stable version on NuGet is 9.2.5 and its page says the package must be licensed. The Apache 2.0 v8 line still receives patches (the latest is 8.5.12 from 21 October 2026). Wolverine is MIT-licensed (MIT: Massachusetts Institute of Technology). This landscape can change; check the license page again on the day before you pick a library.
We need none of them for in-process dispatch between modules. The Vertical Slice series post "Slices with a Mediator: Comparing MediatR, Source-Generated Mediator, and Wolverine" compares mediator libraries in detail; here we write our own forty-line bus:
using Bookstore.BuildingBlocks.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bookstore.BuildingBlocks.Infrastructure.Messaging;
// Library-free bus: finds handlers by event type and runs each in its own scope
internal sealed class InProcessEventBus(IServiceScopeFactory scopes, ILogger<InProcessEventBus> logger) : IEventBus
{
public async Task PublishAsync(IIntegrationEvent @event, CancellationToken ct)
{
Type handlerType = typeof(IIntegrationEventHandler<>).MakeGenericType(@event.GetType());
System.Reflection.MethodInfo handle = handlerType.GetMethod(nameof(IIntegrationEventHandler<>.HandleAsync))!;
int count;
using (IServiceScope probe = scopes.CreateScope())
count = probe.ServiceProvider.GetServices(handlerType).Count();
List<Exception> failures = [];
for (int i = 0; i < count; i++)
{
// One scope per handler: its own DbContext, one failure does not affect the others
using IServiceScope scope = scopes.CreateScope();
object handler = scope.ServiceProvider.GetServices(handlerType).ElementAt(i)!;
try
{
await (Task)handle.Invoke(handler, [@event, ct])!;
}
catch (Exception ex)
{
logger.LogError("{Handler} failed for {Event} {EventId}: {Message}",
handler.GetType().Name, @event.GetType().Name, @event.EventId, ex.Message);
failures.Add(ex);
}
}
if (failures.Count > 0)
throw new AggregateException($"{failures.Count} handler(s) failed for {@event.GetType().Name}", failures);
}
}
The bus builds the IIntegrationEventHandler<OrderPlaced> type from the event's runtime type, finds the handlers registered for it in the DI (Dependency Injection) container and runs each in its own scope. A separate scope means each handler works with its own DbContext: Inventory's handler never sees Shipping's half-finished changes. If one handler fails the others still run, and at the end the failures return to the caller inside an AggregateException. The unbound generic type in nameof(IIntegrationEventHandler<>.HandleAsync) is a feature that arrived with C# 14. A well-known example of this kind of in-process publishing, where the publisher doesn't know its listeners, is .NET's DiagnosticListener: EF Core publishes the CommandExecuted events we used to count SQL commands in post 04 this way, without knowing who listens to them.
The handlers follow the naming from the DDD series and are introduced in each module's registration method: Inventory has ReserveStockWhenOrderPlaced, Shipping CreateShipmentWhenOrderPlaced, Payments RequestPaymentWhenOrderPlaced. Ordering knows none of them.
The Problem with Publishing in Memory
The first flow that comes to mind is to save the order and publish the event right away:
OrderPlaced placed = new(Guid.CreateVersion7(), now, order.Id,
[.. order.Items.Select(i => new OrderPlacedLine(i.BookId, i.Quantity))], order.Total.Amount, order.Total.Currency);
db.Orders.Add(order);
if (options.UseOutbox)
{
// The order and the event in the same SaveChanges, i.e. the same transaction
db.OutboxMessages.Add(OutboxMessage.From(placed));
await db.SaveChangesAsync(ct);
}
else
{
await db.SaveChangesAsync(ct); // 1) the order is committed
await bus.PublishAsync(placed, ct); // 2) the event is dispatched in memory only
}
With Messaging:UseOutbox off, the else branch runs. We played the opening scenario, a deploy missing Shipping's setting. The response the customer got:
HTTP 500
The log:
fail: Bookstore.BuildingBlocks.Infrastructure.Messaging.InProcessEventBus[0]
CreateShipmentWhenOrderPlaced failed for OrderPlaced 01a1b886-6850-778d-999b-8917303de0a1: Shipping:DefaultCarrier is not configured
The state of the database:
orders=1 reservations=2 shipments=0 payments=1 outbox pending=0
The order is committed, stock is reserved, a payment record is open, and there is no shipment. The customer saw a 500 and will most likely place the order again; we will end up with two orders, two reservations and two payment records. We added the setting and restarted the application:
orders=1 reservations=2 shipments=0 payments=1 outbox pending=0
Nothing changed. The event lived only in the process's memory; when the handler failed it was lost and there is no record to retry it from. The same happens without any handler failure: if the process dies in the short window between SaveChangesAsync and PublishAsync (a deploy, running out of memory, a container restart), the order is in the database and the event is nowhere. The Messaging series post "Transactional Outbox and Inbox: Understanding and Designing Around the Dual Write Problem" calls this the dual write problem: writing to two places, with two separate failure windows.
A Minimal Outbox
The solution is to write the event to the database too, in the same transaction as the order. The if branch of the code above does exactly that: the OutboxMessage row is added in the same SaveChanges call as the order, so either both are committed or neither is.
// The event is stored as a row written in the same transaction as the business data
public sealed class OutboxMessage
{
public Guid Id { get; init; }
public required string Type { get; init; }
public required string Payload { get; init; }
public DateTime OccurredAt { get; init; }
public DateTime? ProcessedAt { get; set; }
public int Attempts { get; set; }
public string? LastError { get; set; }
public static OutboxMessage From(IIntegrationEvent @event) => new()
{
Id = @event.EventId,
Type = @event.GetType().FullName!,
Payload = JsonSerializer.Serialize(@event, @event.GetType()),
OccurredAt = @event.OccurredAt
};
}
The outbox table lives in Ordering's own schema, ordering.OutboxMessages. Each module has its own outbox and its own worker:
// One worker per module: reads its own outbox table, hands events to the bus, marks them processed
internal sealed class OutboxProcessor<TDbContext>(
IServiceScopeFactory scopes, IEventBus bus, EventTypeMap types, TimeProvider clock,
ILogger<OutboxProcessor<TDbContext>> logger, TimeSpan interval) : BackgroundService
where TDbContext : DbContext
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(interval, clock);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
using IServiceScope scope = scopes.CreateScope();
TDbContext db = scope.ServiceProvider.GetRequiredService<TDbContext>();
List<OutboxMessage> batch = await db.Set<OutboxMessage>()
.Where(m => m.ProcessedAt == null && m.Attempts < 5)
.OrderBy(m => m.OccurredAt)
.Take(20)
.ToListAsync(stoppingToken);
foreach (OutboxMessage message in batch)
{
try
{
IIntegrationEvent @event = (IIntegrationEvent)JsonSerializer.Deserialize(message.Payload, types.Resolve(message.Type))!;
await bus.PublishAsync(@event, stoppingToken);
message.ProcessedAt = clock.GetUtcNow().UtcDateTime;
}
catch (Exception ex)
{
message.Attempts++;
message.LastError = ex.Message;
logger.LogWarning("Outbox message {Id} failed (attempt {Attempts})", message.Id, message.Attempts);
}
await db.SaveChangesAsync(stoppingToken);
}
}
}
public static IServiceCollection AddOrderingModule(this IServiceCollection services, string connectionString, bool useOutbox)
{
services.AddDbContext<OrderingDbContext>(o => o.UseSqlServer(connectionString));
services.AddSingleton(new OrderingOptions(useOutbox));
services.AddScoped<PlaceOrderHandler>();
services.AddIntegrationEvent<OrderPlaced>();
if (useOutbox)
services.AddOutboxProcessor<OrderingDbContext>(TimeSpan.FromSeconds(5));
return services;
}

Every five seconds the worker reads unprocessed rows, hands the event to the bus and, on success, fills in ProcessedAt. If a handler fails, the row increments its Attempts counter and is retried in the next round; after five attempts it is set aside and someone needs to look at it. We played the same deploy mistake again, this time with the outbox on:
{"orderId":"01a1b886-a30c-742f-9ecf-33c83a2717b9","lines":2,"total":30.00,"currency":"EUR"}
HTTP 201
The customer placed the order successfully; Shipping's problem is no longer their problem. A few seconds later:
warn: Bookstore.BuildingBlocks.Infrastructure.Messaging.OutboxProcessor[0]
Outbox message 01a1b886-a30d-7a57-9d35-b4eb174ad063 failed (attempt 1)
orders=1 reservations=2 shipments=0 payments=1 outbox pending=1
Type Attempts LastError
---- -------- ---------
Bookstore.Ordering.Contracts.OrderPlaced 2 1 handler(s) failed for OrderPlaced (Shipping:DefaultCarrier is not configured)
We added the setting and restarted the application. The worker retried the pending row:
orders=1 reservations=2 shipments=1 payments=1 outbox pending=0
The shipment was created, and the number to notice is reservations=2. In the meantime the event was published three times, and each time Inventory's handler was called too; yet the stock was reserved only once. The reason is in the next section.
This outbox is deliberately minimal. Several worker instances picking up the same rows, ordering guarantees, cleaning up the table and transaction log tailing instead of polling are covered in detail in the Messaging series outbox post. If a single instance of the application runs in your modular monolith, this version is often enough; if you are going to run several instances, read that post first.
Idempotent Handlers: the Inbox
An outbox gives at-least-once delivery: the event arrives at least once, but it may arrive more than once. One handler failing means the same event goes again to the handlers that succeeded in that round. The handlers have to notice:
// Handler name follows the DDD series convention: <Reaction>When<Fact>
internal sealed class ReserveStockWhenOrderPlaced(InventoryDbContext db, TimeProvider clock) : IIntegrationEventHandler<OrderPlaced>
{
public async Task HandleAsync(OrderPlaced @event, CancellationToken ct)
{
if (await db.AlreadyProcessedAsync(@event.EventId, nameof(ReserveStockWhenOrderPlaced), ct))
return; // same event arrived twice: do nothing
foreach (OrderPlacedLine line in @event.Lines)
{
StockItem item = await db.StockItems.SingleAsync(s => s.BookId == line.BookId, ct);
item.Reserved += line.Quantity;
db.Reservations.Add(new StockReservation(Guid.CreateVersion7(), @event.OrderId, line.BookId, line.Quantity));
}
db.MarkProcessed(@event.EventId, nameof(ReserveStockWhenOrderPlaced), clock.GetUtcNow().UtcDateTime);
await db.SaveChangesAsync(ct); // reservation and inbox record in one transaction
}
}
using Bookstore.BuildingBlocks.Messaging;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.BuildingBlocks.Infrastructure.Messaging;
public static class InboxExtensions
{
// True if this event already reached this handler before
public static Task<bool> AlreadyProcessedAsync(this DbContext db, Guid eventId, string handler, CancellationToken ct) =>
db.Set<ProcessedMessage>().AnyAsync(m => m.MessageId == eventId && m.Handler == handler, ct);
public static void MarkProcessed(this DbContext db, Guid eventId, string handler, DateTime now) =>
db.Set<ProcessedMessage>().Add(new ProcessedMessage { MessageId = eventId, Handler = handler, ProcessedAt = now });
}
Each handler writes the id of the event it processed to its own module's ProcessedMessages table, in the same SaveChanges as the business change. When the event arrives a second time the record is already there, so it returns without doing anything. The tables at the end of the scenario:
Schema Handler
------ -------
inventory ReserveStockWhenOrderPlaced
shipping CreateShipmentWhenOrderPlaced
payments RequestPaymentWhenOrderPlaced
Each module's inbox lives in its own schema, because the question "have I processed this event?" must be answered in the same transaction as the handler's own data. A single shared inbox table would make modules write to the same table again. There are other ways to get idempotency (a unique index on a natural key, an "already reserved?" check); the Messaging series post "Idempotent Consumers: What Happens When the Same Message Arrives Twice?" compares the options.
If the Process Crashes
Finally, the second risk from the opening: the process dying between commit and dispatch. With the outbox on we placed an order and, as soon as the response came back, killed the process with kill -9, before the worker's first round:
HTTP 201
orders=1 reservations=0 shipments=0 payments=0 outbox pending=1
The order and the outbox row exist, nothing else does. We restarted the application:
orders=1 reservations=2 shipments=1 payments=1 outbox pending=0
The event was not lost because it was waiting on disk. The same crash with in-memory publishing would have left a shipment and a payment record missing forever.
Common Pitfalls
Publishing the event before the commit
Calling PublishAsync before SaveChangesAsync produces the same mistake in reverse: the handlers reserve stock, then Ordering's commit fails and you are left with stock reserved for an order that doesn't exist.
Not writing idempotent handlers
Setting up an outbox without idempotent handlers trades lost events for events processed twice. Without the inbox, the two retries in the scenario would have produced four reservations.
Putting an entity in the event, or too little data
Carrying the Order entity in the event leaks Ordering's internal model into Contracts. Conversely, carrying only OrderId forces every handler to ask Ordering back. An event should carry the facts its consumers need, in its own records.
One outbox and one worker for all modules
A single OutboxMessages table makes every module write to the same table and breaks the schema isolation of post 06. The outbox is data of the module that publishes.
Using the in-process bus instead of a synchronous call
Events say "something happened", not "do this and give me an answer". Checkout's stock question is still a synchronous contract call; turning it into an event would require a second event and a waiting mechanism to get the answer back.
A Small Experiment
★☆☆ Add a Notification module: an idempotent handler that writes a record to notifications.EmailMessages when OrderPlaced arrives. Confirm that not a single line in Ordering changes.
★★☆ Have Inventory publish an OutOfStock event (through Inventory's own outbox) when it cannot reserve stock. Let Ordering listen to that event and cancel the order. Events now flow in both directions between the two modules; is that a circular dependency?
★★★ Run the application as two instances and place 50 orders at once. Can you catch two workers picking up the same outbox row? Write a query with the UPDLOCK, READPAST hints in SQL Server to lock the row, and compare the result with the recommendations in the Messaging series outbox post.
In this post we made the way modules notify each other durable. In the next post we make each module's data truly its own: a DbContext per module, HasDefaultSchema, a migrations history table per module, schema-level permissions and answers to "we need a join for reporting".