A modular monolith in C# is one of the best architectural choices you can make for a greenfield .NET application. It gives you the structure of microservices -- bounded contexts, enforced module boundaries, event-driven communication -- without the distributed systems complexity that will slow your team down before you even ship v1.
This guide walks through a concrete .NET implementation. Not theory. Not diagrams with arrows. Actual code that you can run, test, and build on. By the end, you will understand how to structure your solution, register modules cleanly, isolate data between modules, and wire up cross-module events using MediatR.
If you have built a traditional monolith before and hit the wall where everything is tangled together, this is the architectural step that fixes that without forcing you to go full microservices.
The Core Principles of a Modular Monolith
Before diving into code, it is worth understanding the four principles that make a modular monolith work. These are not abstract guidelines -- they are constraints you enforce in your solution structure.
1. Single Deployment Unit. The entire application ships as one process. There is no service mesh, no Docker orchestration, no inter-process calls. You get simplicity at runtime while still having clean architecture internally.
2. Enforced Boundaries. Each module exposes a public interface and hides its internals behind internal access modifiers. Other modules cannot reach into your domain model or your database context.
3. Independent Data. Each module owns its own data store. In our implementation, each module gets its own SQLite file and its own DbContext. No shared tables. No shared ORM context.
4. Event-Based Communication. Modules do not call each other directly. When something happens in one module that another module needs to know about, the originating module publishes an integration event. Subscribers handle it. This is exactly the Observer Design Pattern in C# applied at architectural scale.
These four principles work together. Violate one and the others start to erode. The solution structure is what makes them enforceable.
Solution Architecture Overview
Here is the actual solution layout for the demo application:
modular-monolith-demo/
├── src/
│ ├── Host/
│ │ └── Program.cs ← composition root
│ └── Modules/
│ ├── Projects/
│ │ ├── Projects.Domain/ ← internal entities
│ │ ├── Projects.Application/ ← IProjectsModule (public contract)
│ │ ├── Projects.Infrastructure/← EF Core DbContext, extensions
│ │ └── Projects.Contracts/ ← integration events (public)
│ ├── Tasks/
│ │ ├── Tasks.Domain/
│ │ ├── Tasks.Application/
│ │ ├── Tasks.Infrastructure/
│ │ └── Tasks.Contracts/
│ ├── Users/
│ │ ├── Users.Domain/
│ │ ├── Users.Application/
│ │ ├── Users.Infrastructure/
│ │ └── Users.Contracts/
│ └── Notifications/
│ ├── Notifications.Application/
│ └── Notifications.Infrastructure/
└── tests/
├── ModularMonolith.UnitTests/
└── ModularMonolith.IntegrationTests/
The key pattern is the four-layer breakdown per module: Domain, Application, Infrastructure, and Contracts. Domain and Application layers are internal to the module. Contracts are public so other modules can subscribe to events. The Application layer publishes a single public interface (IProjectsModule, ITasksModule, etc.) that is the only way the Host and other modules interact with it.
This is similar to what you would do with the Strategy Design Pattern in C# -- you program to an interface and swap implementations freely behind it.
Principle 1: Module Encapsulation
The encapsulation starts with the domain layer. Domain entities are internal sealed -- nothing outside the module's assembly can instantiate or reference them directly.
// Projects.Domain/Project.cs
namespace Projects.Domain;
internal sealed class Project
{
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public ProjectStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
private Project() { }
public static Project Create(string name, string description)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Project name cannot be empty", nameof(name));
}
return new Project
{
Id = Guid.NewGuid(),
Name = name,
Description = description ?? string.Empty,
Status = ProjectStatus.Active,
CreatedAt = DateTime.UtcNow
};
}
}
The internal sealed combination is doing real work here. sealed prevents unintended inheritance. internal means the class is invisible outside the Projects.Domain assembly. The Host project cannot access Project directly -- it can only go through IProjectsModule.
The application layer module implementation follows the same access rule:
// Projects.Application/ProjectsModule.cs
namespace Projects.Application;
internal sealed class ProjectsModule : IProjectsModule
{
private readonly IProjectsDbContext _dbContext;
private readonly IPublisher _publisher;
public ProjectsModule(IProjectsDbContext dbContext, IPublisher publisher)
{
_dbContext = dbContext;
_publisher = publisher;
}
public async Task<CreateProjectResult> CreateProjectAsync(
string name,
string description,
CancellationToken cancellationToken = default)
{
var project = Project.Create(name, description);
_dbContext.Projects.Add(project);
await _dbContext.SaveChangesAsync(cancellationToken);
await _publisher.Publish(
new Contracts.ProjectCreatedEvent(project.Id, project.Name, project.Description),
cancellationToken);
return new CreateProjectResult(project.Id, project.Name, project.Description);
}
// ... GetProjectAsync, ListProjectsAsync
}
The public surface area of the entire module is just the IProjectsModule interface and the result record types. That is it. The Host registers it cleanly through an extension method:
// Projects.Infrastructure/ProjectsModuleExtensions.cs
namespace Projects.Infrastructure;
public static class ProjectsModuleExtensions
{
public static IServiceCollection AddProjectsModule(
this IServiceCollection services,
string connectionString)
{
services.AddDbContext<ProjectsDbContext>(options =>
options.UseSqlite(connectionString));
services.AddScoped<IProjectsDbContext>(
sp => sp.GetRequiredService<ProjectsDbContext>());
services.AddScoped<IProjectsModule, ProjectsModule>();
return services;
}
}
The call site in Program.cs is a single line: builder.Services.AddProjectsModule("Data Source=projects.db"). Clean, self-contained, and swappable. This is exactly the kind of composable design you get from thinking about your architecture upfront. The Decorator Design Pattern in C# fits naturally on top of this registration pattern when you need cross-cutting concerns like logging or caching around a module.
Principle 2: Event-Driven Cross-Module Communication
Modules do not call each other. Instead, they publish integration events. The Notifications module is the clearest example of this: it knows nothing about how users, projects, or tasks work internally. It only subscribes to their public events.
First, the event contracts. These live in the Contracts project of each module and are the only public exports alongside the module interface:
// 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;
These are MediatR INotification records. They are immutable, self-describing, and version-friendly. Now the Tasks module publishes them from inside TasksModule:
// Tasks.Application/TasksModule.cs (AssignTaskAsync)
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);
}
And the Notifications module handles it -- without any dependency on Tasks.Application or Tasks.Infrastructure:
// 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;
}
}
This is the Observer vs Mediator Pattern in C# distinction in practice. You are using MediatR as an in-process mediator to decouple publishers from subscribers. The Notifications module has zero knowledge of where events come from -- it just reacts to them.
If you later add a new module that also needs to respond to TaskAssignedEvent (say, a billing module that tracks time), you add one handler class. You do not modify the Tasks module at all.
Principle 3: Data Isolation
Each module has its own DbContext and its own SQLite database file. This is the data isolation constraint enforced at the infrastructure layer.
// Tasks.Infrastructure/TasksDbContext.cs
public sealed class TasksDbContext : DbContext, ITasksDbContext
{
DbSet<TaskItem> ITasksDbContext.Tasks => Set<TaskItem>();
public TasksDbContext(DbContextOptions<TasksDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TaskItem>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.ProjectId).IsRequired();
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(1000);
entity.Property(e => e.Status).IsRequired();
entity.Property(e => e.AssignedUserId);
entity.Property(e => e.CreatedAt).IsRequired();
});
}
}
The TasksDbContext implements ITasksDbContext, which is the only way application-layer code accesses the database. The concrete context is never exposed outside the Infrastructure project. The ITasksDbContext interface abstracts the EF Core concerns, which also makes unit testing straightforward.
Notice that TasksDbContext has no idea that UsersDbContext or ProjectsDbContext exist. There are no cross-context navigation properties, no shared migration history, no foreign keys crossing module boundaries. If the Tasks module needs to know about a user, it stores the UserId as a plain Guid -- a reference by identity, not a navigational join.
When you eventually need to scale a specific module, you can extract it to its own service. The database boundary is already there. You just point it at a real database instead of SQLite.
Principle 4: Module Composition in the Host
The Host project (Program.cs) is the composition root. It is the only place that knows about all modules simultaneously. This is where Keyed Services in .NET DI would come into play if you needed multiple implementations of the same interface -- but for modules, the pattern is simpler.
// Host/Program.cs
var builder = WebApplication.CreateBuilder(args);
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);
});
builder.Services.AddUsersModule("Data Source=users.db");
builder.Services.AddProjectsModule("Data Source=projects.db");
builder.Services.AddTasksModule("Data Source=tasks.db");
builder.Services.AddNotificationsModule();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var usersDb = scope.ServiceProvider.GetRequiredService<UsersDbContext>();
await usersDb.Database.EnsureCreatedAsync();
var projectsDb = scope.ServiceProvider.GetRequiredService<ProjectsDbContext>();
await projectsDb.Database.EnsureCreatedAsync();
var tasksDb = scope.ServiceProvider.GetRequiredService<TasksDbContext>();
await tasksDb.Database.EnsureCreatedAsync();
}
MediatR registration is the only place where all four module application assemblies appear together. This is intentional -- MediatR needs to scan assemblies to wire up handlers, so the Host is the right place for it. Each module's extension method handles everything else it needs internally.
The Host does not know how any module works. It knows that modules exist and that they expose public interfaces. That is the right level of coupling. If you are running background processing inside a module, Hosted Services with .NET background workers follow the same composition pattern -- the module's extension method registers the hosted service, the Host just calls AddXyzModule().
The Full API Layer
The API endpoints in Program.cs are thin. They receive HTTP requests, call the appropriate module interface, and return results. No business logic lives here.
app.MapPost("/users", async (RegisterUserRequest request, IUsersModule usersModule) =>
{
var result = await usersModule.RegisterUserAsync(request.Name, request.Email);
return Results.Created($"/users/{result.UserId}", result);
});
app.MapPost("/projects", async (CreateProjectRequest request, IProjectsModule projectsModule) =>
{
var result = await projectsModule.CreateProjectAsync(request.Name, request.Description);
return Results.Created($"/projects/{result.ProjectId}", result);
});
app.MapPost("/tasks/{id:guid}/assign", async (
Guid id,
AssignTaskRequest request,
ITasksModule tasksModule) =>
{
var result = await tasksModule.AssignTaskAsync(id, request.UserId);
return result.Success ? Results.Ok(result) : Results.NotFound();
});
app.MapGet("/notifications", (INotificationsModule notificationsModule) =>
{
var result = notificationsModule.GetRecentNotifications();
return Results.Ok(result);
});
Each endpoint only depends on a single module interface. The /tasks/{id}/assign endpoint triggers the full event chain: task gets assigned, TaskAssignedEvent is published, TaskAssignedEventHandler in the Notifications module fires, a notification is stored. All of this happens in-process, synchronously, with zero network overhead.
Why This Beats a Traditional Monolith
A traditional monolith grows into a big ball of mud. Services call other services directly. Repositories are shared. Business rules from one domain leak into another. By the time your team realizes the problem, untangling it requires months of work.
A modular monolith solves this proactively:
- Testability -- Each module can be tested in isolation. Unit tests mock
IProjectsModule. Integration tests spin up only the module's DbContext, not the entire application. The test suite in the demo splitsUnitTestsandIntegrationTestscleanly along module lines. - Replaceability -- If your Notifications module becomes a bottleneck, you swap the in-memory implementation for a proper message bus handler. The interface does not change. Nothing else needs to know.
- Path to microservices -- If a module genuinely needs to scale independently, you extract it. The database boundary is already there. The event contracts are already there. The interface is already there. You are not refactoring -- you are just deploying differently.
- Faster onboarding -- New developers can own a single module without understanding the full system. The
internalaccess modifier physically prevents them from accidentally coupling things together.
The design patterns that make this work are not complicated. The Factory Method Design Pattern in C# shows up in the Project.Create() and TaskItem.Create() factory methods. The Singleton Design Pattern in C# applies to things like the InMemoryNotificationStore registered as a singleton. Standard patterns, applied deliberately, produce maintainable architecture.
What Makes This Different from Microservices
The difference is not just architectural philosophy -- it is operational reality.
| Modular Monolith | Microservices | |
|---|---|---|
| Deployment | Single process | Multiple independent services |
| Communication | In-process, in-memory | Network calls (HTTP, gRPC, message bus) |
| Data | Separate DbContexts, same host | Separate databases, separate services |
| Failure modes | In-process exceptions | Network failures, timeouts, partial failures |
| Overhead | Near zero | Serialization, network latency, service discovery |
| Team structure | Works well for small/mid teams | Requires dedicated team per service |
In the demo, when TasksModule publishes TaskAssignedEvent, MediatR delivers it to TaskAssignedEventHandler in the same process, on the same thread, with no serialization, no retry logic, and no dead-letter queues to manage. That is not a limitation -- that is a feature when you are building a product that needs to ship and iterate quickly.
The modular monolith gets you 80% of the organizational benefits of microservices at 20% of the operational cost. For most teams, that is the right trade-off.
FAQ
What is the difference between a modular monolith and a layered monolith?
A layered monolith organizes code by technical concern -- Controllers, Services, Repositories, Data. A modular monolith organizes by business domain. In a modular monolith, the Projects module owns its controllers, services, repositories, and data together. Cross-cutting concerns are handled through events or shared infrastructure abstractions, not by calling into another domain's service layer.
Do modules have to use separate databases?
No, but they should use separate DbContexts with separate schemas or table prefixes at minimum. In the demo, SQLite makes separate files natural. In production you might use one SQL Server instance with separate schemas per module -- projects.Projects, tasks.Tasks, etc. The goal is no cross-module joins at the ORM layer.
Can modules communicate synchronously?
The recommended approach is event-based communication through MediatR. But modules can also expose read-only query interfaces if you need synchronous data access. The rule to follow is that one module never modifies another module's data directly. It may read it through a defined contract.
How do you handle transactions that span multiple modules?
You generally avoid them. In the demo, when a task is assigned, the Tasks module saves its state and then publishes an event. The Notifications module handles the event in the same request. If the notification fails, the task assignment still succeeded. This is eventual consistency within a single process -- it is simpler than distributed transactions and usually the right trade-off.
How does this scale when the team grows?
Modules map well to team ownership. Each module is a bounded context -- a natural unit of ownership for a small team or individual developer. The internal access modifier is enforced by the compiler, which means Conway's Law actually works in your favor here: team boundaries and code boundaries align.
When should I extract a module to a microservice?
When you have a clear, data-driven reason: the module has significantly different scaling requirements, you need independent deployment cycles, or you have a team large enough to own its own service in production. Do not extract preemptively. Wait until the pain is real. The modular monolith gives you the option without forcing the decision.
How do you test cross-module event flows in integration tests?
The demo's TaskAssignmentIntegrationTests project shows this. You spin up the full application in-process using WebApplicationFactory, hit the API endpoints, and assert on the resulting state across modules. Because everything runs in-process, there is no need for test containers or message bus simulators for the event handling path.
Conclusion
A modular monolith in C# is not a compromise -- it is a deliberate architectural choice. It gives you enforced domain boundaries, testable modules, and a clear migration path to microservices if you ever need it, without the operational complexity of running distributed systems on day one.
The four principles -- single deployment, enforced boundaries, independent data, and event-based communication -- work together. The solution structure enforces them. The internal keyword enforces them at the compiler level. MediatR handles the event bus so you do not build one yourself.
Start with the four-layer module structure: Domain, Application, Infrastructure, Contracts. Register each module through its own extension method. Let the Host be the only composition point. Publish integration events when state changes. Everything else follows from these decisions.
The demo application in this guide is runnable -- no setup beyond dotnet run. Clone it, walk through the code, and use it as a template for your next .NET project.

