You've sliced your application into modules. Tasks, Projects, Notifications -- each with its own folder, its own database context, its own bounded context. The architecture looks clean on paper.
Then comes the moment of truth: the assign-task operation needs to notify the Notifications module. You reach for a project reference, add it to the .csproj, and just like that you've started tearing down the walls you built. Module communication in modular monolith C# is the challenge that separates a well-architected modular application from a tangle of coupled assemblies wearing a "modular" badge.
This article walks through the concrete patterns for module communication modular monolith C# style -- drawn from a real .NET 9 demo project you can reference directly. You'll see actual code, understand why each approach exists, and learn the rules that keep your modules from sliding back into the big ball of mud.
The Three Communication Options
Before diving into code, it helps to lay out the landscape. When it comes to module communication in a modular monolith C# application, there are three practical options:
Option A -- Module interface (synchronous calls)
Each module exposes a public interface like ITasksModule or IProjectsModule. Other parts of the system depend on that interface, never on the concrete implementation. This is type-safe and appropriate when you need a query result immediately.
Option B -- In-process domain events via MediatR
The publishing module fires an INotification through MediatR's IPublisher. Any interested module registers an INotificationHandler<T> and reacts. The publisher doesn't know -- or care -- who is listening. This is the primary pattern for cross-module side effects.
Option C -- Shared contracts (events and result DTOs) Contracts projects hold only the public surface: event records and result DTOs. No domain entities, no DbContext, no internal services. Other modules reference the contracts project, not the application or infrastructure projects.
These three options compose naturally. You'll use all of them.
Anti-Pattern: Direct Project References
Here's the trap. It feels natural to add a ProjectReference from Tasks.Application to Projects.Application. You just need to look up a project name, after all. This is the most common mistake in modular monolith C# module communication -- and it quietly destroys your architecture.
<!-- ❌ NEVER reference another module's Application or Infrastructure layer -->
<ProjectReference Include="......ProjectsProjects.ApplicationProjects.Application.csproj" />
The moment you do this, Tasks depends on Projects' EF Core entities, its DbContext, and its internal services. Any internal refactor in Projects can break Tasks. You've recreated the big ball of mud -- just with more folders.
The rule is firm: application and infrastructure layers are never referenced across module boundaries. Only the contracts layer is a legitimate cross-module reference.
Notice how Tasks.Application.csproj is structured in the demo:
<ItemGroup>
<ProjectReference Include="..Tasks.DomainTasks.Domain.csproj" />
<ProjectReference Include="..Tasks.ContractsTasks.Contracts.csproj" />
</ItemGroup>
Tasks.Application only references its own domain and its own contracts. No other module's application or infrastructure layer in sight.
Pattern 1: In-Process Domain Events with MediatR
MediatR's INotification / INotificationHandler<T> pattern is the backbone of cross-module communication. It follows the Observer design pattern -- a publisher broadcasts an event and multiple subscribers react independently. If you want to understand how this relates to the mediator side of MediatR, this deep dive on Observer vs Mediator in C# covers the distinction well.
Every integration event lives in a contracts project. Here's the full content of Tasks.Contracts/TaskEvents.cs:
using MediatR;
namespace Tasks.Contracts;
public sealed record TaskAssignedEvent(Guid TaskId, Guid ProjectId, Guid UserId, string TaskTitle) : INotification;
public sealed record TaskCompletedEvent(Guid TaskId, Guid ProjectId, string TaskTitle) : INotification;
And Projects.Contracts/ProjectCreatedEvent.cs:
using MediatR;
namespace Projects.Contracts;
public sealed record ProjectCreatedEvent(Guid ProjectId, string Name, string Description) : INotification;
A few things to observe:
- These are
sealed recordtypes -- immutable, with structural equality. - They implement
INotification, notIRequest<T>. There is no return value. Fire and forget. - They live in the
*.Contractsproject, which only depends onMediatR.Contracts-- not on any domain entities or application services. - They carry only primitive data (Guid, string). No domain objects leak across the boundary.
The Contracts Pattern: What Goes in a Contracts Project
The contracts project is the module's public API surface. Think of it as the module's stable, outward-facing vocabulary.
What belongs in a contracts project:
- Integration events (
INotificationimplementations) - Result records returned by module interface methods
- Shared value objects if truly necessary -- but keep the surface minimal
What does NOT belong:
- Domain entities (
TaskItem,Project, etc.) DbContextimplementations- Infrastructure configuration or internal services
Look at Notifications.Application.csproj to see this rule enforced structurally:
<ItemGroup>
<ProjectReference Include="....UsersUsers.ContractsUsers.Contracts.csproj" />
<ProjectReference Include="....ProjectsProjects.ContractsProjects.Contracts.csproj" />
<ProjectReference Include="....TasksTasks.ContractsTasks.Contracts.csproj" />
</ItemGroup>
Notifications depends on three contracts projects -- never on the application or infrastructure layer of any other module. Even if Tasks completely rewrites its internal domain model, the only breaking change that can affect Notifications is a change to Tasks.Contracts.
Publishing Events
Events are published from the application layer, after the state mutation is persisted. In Tasks.Application/TasksModule.cs, the AssignTaskAsync method demonstrates the pattern:
public async Task<AssignTaskResult> AssignTaskAsync(
Guid taskId,
Guid userId,
CancellationToken cancellationToken = default)
{
var task = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken);
if (task == null)
{
return new AssignTaskResult(taskId, userId, false);
}
task.Assign(userId);
await _dbContext.SaveChangesAsync(cancellationToken);
await _publisher.Publish(
new Contracts.TaskAssignedEvent(task.Id, task.ProjectId, userId, task.Title),
cancellationToken);
return new AssignTaskResult(taskId, userId, true);
}
The sequence is intentional:
- Load the aggregate.
- Apply the domain operation (
task.Assign(userId)). - Persist changes.
- Publish the event.
Publishing after save ensures the event reflects committed state. If the save fails, no event is published -- consistent behavior without a saga or outbox. For a purely in-process monolith, this is the right approach. If you later extract modules to separate services, you'd replace this with a transactional outbox.
IPublisher is injected through the constructor. The TasksModule doesn't know what's listening, or whether anything is listening at all.
Handling Events
The Notifications module listens for events from multiple modules. All handlers are internal sealed -- they're implementation details, invisible to anything outside the module.
Here's TaskAssignedEventHandler from Notifications.Application/Handlers/EventHandlers.cs:
internal sealed class TaskAssignedEventHandler : INotificationHandler<TaskAssignedEvent>
{
private readonly INotificationStore _notificationStore;
public TaskAssignedEventHandler(INotificationStore notificationStore)
{
_notificationStore = notificationStore;
}
public Task Handle(TaskAssignedEvent notification, CancellationToken cancellationToken)
{
var message = $"Task assigned: {notification.TaskTitle} assigned to user {notification.UserId}";
_notificationStore.Add(message);
return Task.CompletedTask;
}
}
The internal sealed modifier is crucial. Nothing outside Notifications.Application can directly instantiate or reference TaskAssignedEventHandler. MediatR discovers it through assembly scanning at startup -- no explicit per-handler registration needed.
The same file contains handlers for ProjectCreatedEvent, TaskCompletedEvent, and UserRegisteredEvent -- all internal sealed, all completely unknown to the modules that publish those events. The Tasks module publishes without knowing about TaskAssignedEventHandler. The Notifications module handles the event without knowing how tasks are assigned.
MediatR Registration
For the pub/sub wiring to work, MediatR must scan every module's application assembly at startup. Host/Program.cs handles this:
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IUsersModule).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IProjectsModule).Assembly);
cfg.RegisterServicesFromAssembly(typeof(ITasksModule).Assembly);
cfg.RegisterServicesFromAssembly(typeof(INotificationsModule).Assembly);
});
Each RegisterServicesFromAssembly call scans one module's assembly and registers all INotificationHandler<T> implementations it finds. Handlers can stay internal because MediatR uses reflection -- access modifiers don't block registration.
The host project is the composition root. It knows about every module's application assembly and is the only project that does. Individual modules don't reference each other.
Pattern 2: Module Interface for Queries
In-process events work well for fire-and-forget notifications. But sometimes you need a synchronous result: "What is the status of this project?" "Does this user exist?" In those cases, the module interface pattern is the right tool.
Each module exposes a public interface for its operations. Here's IProjectsModule:
namespace Projects.Application;
public interface IProjectsModule
{
Task<CreateProjectResult> CreateProjectAsync(
string name,
string description,
CancellationToken cancellationToken = default);
Task<GetProjectResult?> GetProjectAsync(
Guid projectId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<ProjectListResult>> ListProjectsAsync(
CancellationToken cancellationToken = default);
}
public sealed record GetProjectResult(Guid ProjectId, string Name, string Description, string Status);
public sealed record CreateProjectResult(Guid ProjectId, string Name, string Description);
public sealed record ProjectListResult(Guid ProjectId, string Name, string Status);
If the Tasks module needs to validate that a project exists before creating a task, it injects IProjectsModule and calls GetProjectAsync. The dependency is on the public interface, not on any Projects implementation class.
This mirrors the Strategy design pattern: the caller depends on an abstraction and the DI container substitutes the concrete implementation at runtime. The return types -- GetProjectResult, CreateProjectResult -- are sealed record types defined alongside the interface, forming the module's complete public API surface.
You can also apply the Decorator design pattern to module interfaces. A caching decorator that wraps IProjectsModule adds a read-through cache without touching the Projects implementation -- one of the cleanest wins the pattern offers.
Choosing Between Patterns
Here's a practical decision guide for modular monolith C# module communication. Use it when you're unsure which pattern fits your scenario:
| Scenario | Pattern | Reason |
|---|---|---|
| Module A notifies Module B of a state change | In-process event (INotification) |
Publisher stays decoupled from all consumers |
| Multiple modules need to react to one event | In-process event (INotification) |
MediatR fans out to all registered handlers |
| Module A needs to read data owned by Module B | Module interface (IXxxModule) |
Synchronous call, delivers a typed result |
| Module A needs to validate against Module B's data | Module interface (IXxxModule) |
Caller needs an answer before proceeding |
| Module A needs to trigger a write in Module B | Reconsider the boundary | Write operations crossing modules are a design smell |
The last row deserves emphasis. If you find yourself wanting to call a write operation on another module's interface, pause. It usually means either the bounded context is wrong -- the operation belongs entirely in one module -- or you need a workflow layer that coordinates both. Reaching directly into another module's write path is the fastest way to couple your modules at the worst possible level.
Avoiding the Distributed Monolith Trap
"Distributed monolith" describes a microservices system where services are so tightly coupled that they must deploy together and fail together. A poorly designed modular monolith C# application can exhibit the same anti-patterns in a single process -- just with less operational overhead. The patterns for module communication in a modular monolith that we've covered here are specifically designed to prevent that outcome.
The patterns above prevent this by keeping coupling at the contracts boundary. Contracts are small, stable, and intentional. They evolve carefully because they're explicitly public. Internal types -- domain entities, DbContext, handlers -- are internal and can change freely without affecting other modules.
When you eventually want to extract a module to a separate service, the work is bounded:
- Replace
IPublisher.Publishwith a message broker publish. - Replace the in-process
INotificationHandler<T>with a message consumer. - Replace the
IXxxModuleDI registration with an HTTP or gRPC client implementation.
The module's internal code -- its domain, its application logic, its database schema -- doesn't change. The contracts and interfaces remain stable. If you want to build flexible, swappable service implementations, combining this with the Singleton pattern for shared state and the Factory Method pattern for object creation rounds out the toolkit.
FAQ
What is the difference between an integration event and a domain event?
A domain event represents something that happened within a single module's bounded context -- typically used internally to trigger side effects within the same aggregate or within the same module. An integration event crosses module boundaries and is designed for external consumers. In the demo, TaskAssignedEvent in Tasks.Contracts is an integration event because it's intentionally published for other modules to consume. A domain event would stay internal to the Tasks module and never appear in a contracts project.
Can I have multiple handlers for the same event?
Yes. MediatR calls every registered INotificationHandler<T> for a given notification type. If both the Notifications module and a future Analytics module register handlers for TaskAssignedEvent, both execute. This fan-out behavior is one of the key strengths of the pub/sub model -- the publisher never changes as new consumers are added.
Should I use IRequest or INotification for cross-module communication?
Use INotification when you don't need a response. Use IRequest<T> for in-module CQRS operations -- queries and commands within a single module. Do not use IRequest<T> for cross-module communication, because then you'd be coupling a consumer directly to the handler type in another module.
What happens if a handler throws an exception?
With MediatR's default behavior, an exception in any handler propagates back to the publisher and prevents subsequent handlers from running. If you need resilience -- where one handler's failure doesn't affect others -- you can implement a custom IPublisher wrapper that catches per-handler exceptions, or configure retry policies with Polly on individual handlers.
How do I test a module in isolation?
Because each module exposes only a public interface and contracts, unit testing is clean. Mock IPublisher to verify events are published with the correct data. Mock IXxxModule when the module under test calls another module's interface. No in-process MediatR wiring is needed for unit tests -- just a simple Mock<IPublisher> assertion.
When should I consider extracting a module to a microservice?
A modular monolith is a valid long-term architecture, not just a stepping stone. The question is whether a specific module has materially different scaling, deployment, or team ownership requirements. If yes, extract it -- the clean boundaries make it straightforward. If the answer is no, the modular monolith gives you strong isolation with far lower operational overhead.
Does the Notifications module know about the Tasks module's domain?
No. Notifications.Application references only Tasks.Contracts -- the event records. It has no knowledge of TaskItem, TaskStatus, or any other internal domain type. If Tasks renames its internal TaskStatus enum or refactors its domain model, Notifications is completely unaffected.
Conclusion
Module communication in modular monolith C# comes down to a single discipline: never let implementation details cross module boundaries. In-process events via MediatR handle side effects cleanly, with zero coupling between publisher and subscriber. Module interfaces provide synchronous query access without exposing internal state. Shared contracts define the stable, intentional API surface that both sides depend on.
The patterns shown here -- from TaskAssignedEvent as an INotification, to the internal sealed handlers in Notifications.Application, to the csproj that references only contracts projects -- represent a complete, working approach to module communication in a modular monolith C# application. They're what keeps the architecture honest over time.
If you want to deepen your understanding of the design patterns behind these approaches, start with the Observer design pattern in C# for the pub/sub intuition, and the Builder design pattern in C# for constructing complex configuration objects cleanly. Solid design pattern knowledge translates directly into better module design.

