Modular Monolith Communication Patterns in .NET (2026 Guide)
Learn how modules talk to each other in a .NET modular monolith using public APIs and integration events, with simple diagrams, code, and clear rules.
The big school office
Imagine a large school in your town. It has many sections: the admissions office, the fees office, the library, and the exam office.
Each section has its own room, its own register, and its own staff. The library staff do not walk into the fees room and open the cash box. The exam office does not dig through the admissions files on its own. That would be chaos.
So how do they work together? Two simple ways.
Sometimes one office asks another office a direct question and waits for the answer. The exam office calls the fees office and asks, "Has this student paid the fees?" They wait on the line until the fees clerk checks the register and says "Yes" or "No". This is a direct request.
Other times, one office just puts up a notice on the common board. When a new student joins, the admissions office pins a note: "New student admitted: Asha, Class 6." The library reads it and makes a library card. The fees office reads it and opens a fee account. Admissions does not call each office one by one. It announces once, and whoever cares reacts in their own time.
A modular monolith works exactly like this school. It is one building (one application, one deployment), but inside it has clear sections called modules. And those modules talk to each other in these same two ways: a direct call (synchronous) or a notice on the board (an event). This article is about those two communication patterns, when to use each, and how to write them in .NET.
A quick recap of what a module is
Before we talk about communication, let us be sure about the word module.
A module is a slice of your app that owns one job and one set of data. An Orders module owns orders. A Catalog module owns products. A Shipping module owns deliveries. Each module keeps its own classes, its own database tables, and its own rules. From the outside, a module shows only a tiny, polished public face. Everything else stays hidden using the C# internal keyword.
The golden rule is this: a module never reaches into another module's private classes or tables. If Orders needs something from Catalog, it must ask through Catalog's public door. Communication patterns are simply the agreed shapes of those doors.
The two patterns at a glance
There are two main communication patterns inside a modular monolith. Almost everything you build will use one of these, or a mix of both.
| Pattern | Style | Best for | Coupling |
|---|---|---|---|
| Public API (method call) | Synchronous, you wait for the answer | "I need data or a yes/no right now" | Tighter, but explicit |
| Integration event | Asynchronous, fire and forget | "I want to announce that something happened" | Loose, modules stay independent |
Let us look at each one slowly.
Pattern 1: Public API calls (the direct question)
This is the simplest pattern. One module exposes a public interface. Another module injects that interface and calls a method on it. Because both modules live in the same process, the call is just a normal in-memory method call. It is extremely fast. There is no network, no JSON, no waiting on a socket.
Think back to the school. The exam office picks up the phone and asks the fees office a question, then waits for the answer. That is a synchronous call.
How it looks in code
The trick in .NET is that the interface is public, but the class that implements it is internal. Other modules can see and use the interface. They can never see the class behind it. Dependency injection wires them together at startup.
First, the Catalog module puts a small contract in a shared Contracts project:
// Catalog.Contracts project — shared with other modules
namespace Catalog.Contracts;
public interface ICatalogModuleApi
{
Task<ProductInfo?> GetProductAsync(Guid productId, CancellationToken ct);
}
public record ProductInfo(Guid Id, string Name, decimal Price, int StockOnHand);The real class lives inside the Catalog module and is marked internal, so no other module can touch it directly:
// Catalog module — internal, hidden from everyone else
internal sealed class CatalogModuleApi : ICatalogModuleApi
{
private readonly CatalogDbContext _db;
public CatalogModuleApi(CatalogDbContext db) => _db = db;
public async Task<ProductInfo?> GetProductAsync(Guid productId, CancellationToken ct)
{
var product = await _db.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == productId, ct);
return product is null
? null
: new ProductInfo(product.Id, product.Name, product.Price, product.StockOnHand);
}
}Now the Orders module can ask Catalog a direct question. It only knows the interface:
// Orders module — depends only on Catalog.Contracts
internal sealed class PlaceOrderHandler
{
private readonly ICatalogModuleApi _catalog;
public PlaceOrderHandler(ICatalogModuleApi catalog) => _catalog = catalog;
public async Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
var product = await _catalog.GetProductAsync(cmd.ProductId, ct);
if (product is null || product.StockOnHand < cmd.Quantity)
throw new InvalidOperationException("Product not available.");
// ... continue creating the order
}
}Notice what happened. Orders got the exact data it needed, right away, and it never saw a single Catalog table or entity. It only saw ProductInfo, a clean little record made on purpose for sharing.
How a public API call flows
Steps
Define interface
Put it in Contracts
Hide implementation
Mark class internal
Register in DI
Map interface to class
Inject and call
Other module awaits result
When public API calls are the right choice
Use a direct call when you need an answer before you can continue. Checking stock before placing an order is a perfect example. You cannot create the order until you know the product exists and is available. Waiting is the whole point.
The trade-off is coupling. When Orders calls Catalog, it now depends on Catalog being present and working. If Catalog throws, the order fails. That is sometimes exactly what you want. Just know that you are choosing tighter, explicit coupling on purpose.
Pattern 2: Integration events (the notice board)
The second pattern flips the relationship around. Instead of one module asking another, a module simply announces that something happened and moves on. It does not know or care who is listening. Other modules subscribe and react when they are ready.
This is the school notice board again. Admissions pins one note. The library and the fees office both read it later and do their own work. Admissions never waits.
Domain events vs integration events
This is a point that trips up many beginners, so let us be clear.
| Event type | Lives where | Who may listen | Example |
|---|---|---|---|
| Domain event | Inside one module only | Only that same module | "OrderLineAdded" |
| Integration event | Crosses module boundaries | Any other module | "OrderPlaced" |
A domain event is private. It is part of one module's inner story and never leaves the building. An integration event is a public announcement, a real contract that other modules are allowed to depend on. Only integration events go on the shared board.
Publishing an integration event
The integration event itself is just a small record placed in a shared Contracts project, so other modules can recognise it:
// Orders.Contracts — shared
namespace Orders.Contracts;
public record OrderPlacedIntegrationEvent(
Guid OrderId,
Guid CustomerId,
decimal Total,
DateTime PlacedAtUtc);When the order is saved, the Orders module publishes the event on an in-process event bus:
// Orders module — after the order is safely saved
public async Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Lines);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
await _eventBus.PublishAsync(
new OrderPlacedIntegrationEvent(
order.Id, order.CustomerId, order.Total, DateTime.UtcNow),
ct);
}Other modules subscribe with a small handler. The Notifications module, for example, just wants to send an email:
// Notifications module — reacts on its own time
internal sealed class SendOrderEmailHandler
: IIntegrationEventHandler<OrderPlacedIntegrationEvent>
{
private readonly IEmailSender _email;
public SendOrderEmailHandler(IEmailSender email) => _email = email;
public async Task HandleAsync(OrderPlacedIntegrationEvent e, CancellationToken ct)
{
await _email.SendAsync(e.CustomerId, $"Your order {e.OrderId} is confirmed!", ct);
}
}The Orders module has no idea that an email gets sent. Tomorrow you could add a Loyalty module that gives points on every order. You would just add a new handler. You would not touch the Orders code at all. That is the power of loose coupling.
How an integration event flows
Steps
Save change
Order is stored first
Publish event
OrderPlaced goes to bus
Bus delivers
To all subscribers
Handlers react
Email, shipping, loyalty
A note on in-process buses and tooling
In a modular monolith both the publisher and the subscriber live in the same process, so you do not need a network message broker like RabbitMQ or Azure Service Bus. A simple in-process event bus is enough. This is a big advantage: it is fast and has almost no moving parts.
Many .NET teams used to reach for MediatR or MassTransit to power this. Be aware that as of 2025 both moved to a commercial licence for many uses. They are still excellent and still popular, but you should check the licence terms for your project, or consider open-source options like Wolverine, or even a tiny hand-written bus for a small app. The pattern matters more than the library.
Choosing between the two patterns
Here is a simple way to decide. Ask yourself one question: "Do I need an answer to keep going?"
If you need data or a yes/no right now to continue, use a public API call. Checking stock, fetching a price, validating a customer: these all need an immediate answer.
If you only want to say that something happened and let others react, use an integration event. Order placed, payment received, user registered: announce and move on.
A real app uses both. The Orders module might call Catalog to check stock (it needs the answer), then publish OrderPlaced to tell everyone else (it does not need an answer).
Common mistakes to avoid
Even with these two clean patterns, beginners slip into a few traps. Keep an eye out for these.
Sharing the database between modules. The fastest way to ruin a modular monolith is to let one module read another module's tables directly with a SQL query. The boundary instantly dies. Always go through the public API or events, never the raw tables.
Leaking internal classes. If your integration event carries a full Order entity instead of a small DTO record, you have just shared your private model with the world. Always publish small, purpose-built records like OrderPlacedIntegrationEvent, never your real entities.
Publishing the event before saving. If you publish OrderPlaced and then the database save fails, you have told everyone about an order that does not exist. Save first, publish after. For full safety, advanced teams use the outbox pattern, which writes the event into the same database transaction and sends it slightly later.
Using events when you needed an answer. Events are not magic. If you fire an event and then immediately need its result, you have made your life harder. That was a job for a direct call.
The safe order of operations
Steps
Validate via API
Synchronous check
Save to DB
Commit the change
Publish event
Only after commit
Others react
Async handlers
Why this pays off later
Here is the quiet bonus. Suppose one day your Shipping module grows huge and busy, and you want to pull it out into its own microservice. Because Shipping already talked to the rest of the app only through a public interface and integration events, the change is small. You swap the in-process event bus for a real message broker, and you swap the in-process call for an HTTP or gRPC call. The shape of the communication stays the same.
This is the whole promise of the modular monolith: you get clean boundaries and the freedom to split later, without paying the price of distributed systems today. Good communication patterns are what make that promise real.
References and further reading
- Modular monoliths — Microsoft .NET architecture guidance
- Modular Monolith Communication Patterns — Milan Jovanović
- Internal vs. Public APIs in Modular Monoliths — Milan Jovanović
- Communication via Messages (Events) — ABP.IO docs
- Modular Monoliths — Wolverine docs
Quick recap
- A modular monolith is one app with clear inner modules, each owning its own code and data.
- Modules talk in two ways: synchronous public API calls and asynchronous integration events.
- Use a public API call when you need an answer right now (like a stock check). It is fast and explicit, but couples the modules.
- Use an integration event when you only want to announce something happened. Publishers do not know who listens, which keeps modules loosely coupled.
- Keep interfaces and events public in a shared Contracts project, but keep the real classes internal.
- Domain events stay inside one module; only integration events cross boundaries.
- In a monolith an in-process event bus is enough; you only need a broker after you split a module out.
- Save first, then publish, and never share databases or leak entities across modules.
Related Posts
What Is a Modular Monolith? A Beginner-Friendly Guide for .NET
Understand the modular monolith in simple words: one app, strong internal walls. Learn how it compares to monoliths and microservices, why it is the 2026 default for most .NET teams, and how to build one.
Internal vs Public APIs in Modular Monoliths (.NET Guide)
Learn the difference between internal and public APIs in a .NET modular monolith, why module boundaries matter, and how to expose only safe contracts to other modules.
How to Keep Your Data Boundaries Intact in a Modular Monolith (.NET)
Learn simple, practical ways to keep data boundaries strong in a .NET modular monolith using separate schemas, one DbContext per module, and events instead of cross-module joins.
Where Vertical Slices Fit Inside the Modular Monolith
A simple guide to how vertical slices live inside the modules of a modular monolith in .NET, with diagrams, code, tables, and everyday examples.
Monolith to Microservices: How a Modular Monolith Helps
Learn how a modular monolith makes the move from monolith to microservices safe and easy in .NET, using clean boundaries, the Strangler Fig pattern, and small steps.
Breaking It Down: How to Migrate Your Modular Monolith to Microservices
A friendly, step-by-step guide to safely move from a .NET modular monolith to microservices using the Strangler Fig pattern, without a risky big-bang rewrite.