You've heard the term "modular monolith" enough times that it's starting to sound like an architecture worth trying. Good instinct. Build modular monolith C# projects and you get the deployment simplicity of a monolith combined with the bounded-context isolation that makes large codebases actually maintainable. This is the step-by-step guide to building a real modular monolith in .NET 9.
We're going to start from zero -- no boilerplate, no starter templates -- and build a working solution with a Projects module, wire in a Tasks module, and show how cross-module communication works without breaking encapsulation. Every code snippet in this guide comes directly from a working reference application, so you're not just reading theory.
Prerequisites
Before diving in, make sure you have the following ready:
- .NET 9 SDK installed and available on your PATH
- A working understanding of ASP.NET Core minimal APIs
- Basic familiarity with Entity Framework Core (migrations, DbContext)
- MediatR installed via NuGet (we use it for in-process event publishing)
The solution uses SQLite so you won't need a running database server during development.
Step 1: Create the Solution Structure
The folder layout is the single most important decision in a modular monolith. Each module gets its own four-layer stack: Domain, Application, Infrastructure, and Contracts. The Host project is a thin composition root that wires them all together.
Run the following commands to scaffold the solution:
# Create the solution
dotnet new sln -n ModularMonolithDemo
# Host (composition root)
dotnet new webapi -n Host -o src/Host --no-https
# Projects module layers
dotnet new classlib -n Projects.Domain -o src/Modules/Projects/Projects.Domain
dotnet new classlib -n Projects.Application -o src/Modules/Projects/Projects.Application
dotnet new classlib -n Projects.Infrastructure -o src/Modules/Projects/Projects.Infrastructure
dotnet new classlib -n Projects.Contracts -o src/Modules/Projects/Projects.Contracts
# Tasks module layers (added in Step 7)
dotnet new classlib -n Tasks.Domain -o src/Modules/Tasks/Tasks.Domain
dotnet new classlib -n Tasks.Application -o src/Modules/Tasks/Tasks.Application
dotnet new classlib -n Tasks.Infrastructure -o src/Modules/Tasks/Tasks.Infrastructure
dotnet new classlib -n Tasks.Contracts -o src/Modules/Tasks/Tasks.Contracts
# Notifications module
dotnet new classlib -n Notifications.Application -o src/Modules/Notifications/Notifications.Application
dotnet new classlib -n Notifications.Infrastructure -o src/Modules/Notifications/Notifications.Infrastructure
# Add everything to the solution
dotnet sln add src/Host/Host.csproj
dotnet sln add src/Modules/Projects/Projects.Domain/Projects.Domain.csproj
dotnet sln add src/Modules/Projects/Projects.Application/Projects.Application.csproj
dotnet sln add src/Modules/Projects/Projects.Infrastructure/Projects.Infrastructure.csproj
dotnet sln add src/Modules/Projects/Projects.Contracts/Projects.Contracts.csproj
Now wire up project references. The dependency rule is strict: Domain has no dependencies. Application depends on Domain. Infrastructure depends on Application. Contracts is a thin package that only the publishing side and consumers reference.
# Application depends on Domain and Contracts
dotnet add src/Modules/Projects/Projects.Application/Projects.Application.csproj reference
src/Modules/Projects/Projects.Domain/Projects.Domain.csproj
dotnet add src/Modules/Projects/Projects.Application/Projects.Application.csproj reference
src/Modules/Projects/Projects.Contracts/Projects.Contracts.csproj
# Infrastructure depends on Application (and transitively Domain)
dotnet add src/Modules/Projects/Projects.Infrastructure/Projects.Infrastructure.csproj reference
src/Modules/Projects/Projects.Application/Projects.Application.csproj
This reference graph enforces that the domain model never leaks outside its module. The Host project only knows about Application interfaces and Infrastructure extension methods -- never the domain internals.
Step 2: Define the Domain Layer
The Domain layer contains your entities. Nothing else lives here: no EF Core references, no MediatR, no HTTP concerns. Just pure business logic.
Here's the Project entity from the reference app:
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
};
}
public void Complete() => Status = ProjectStatus.Completed;
public void Cancel() => Status = ProjectStatus.Cancelled;
}
Notice the private constructor and the static Create factory method. This is the Factory Method design pattern at work -- it ensures every Project instance is valid on creation and centralizes the construction logic. The entity is internal sealed, which means nothing outside the Domain assembly can instantiate it directly.
The ProjectStatus enum lives in the same namespace:
namespace Projects.Domain;
internal enum ProjectStatus
{
Active,
Completed,
Cancelled
}
Keeping the status type internal is intentional. The Application layer maps it to a string when building result DTOs, so external callers never depend on the internal enum.
Step 3: Build the Application Layer
The Application layer is where use cases live. It defines:
- The public interface (
IProjectsModule) that callers depend on - The internal DbContext interface (
IProjectsDbContext) that the module uses to persist data - The internal implementation (
ProjectsModule) that satisfies both contracts
Start with the interfaces:
// IProjectsDbContext.cs
using Microsoft.EntityFrameworkCore;
using Projects.Domain;
namespace Projects.Application;
internal interface IProjectsDbContext
{
DbSet<Project> Projects { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
// IProjectsModule.cs
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 CreateProjectResult(Guid ProjectId, string Name, string Description);
public sealed record GetProjectResult(Guid ProjectId, string Name, string Description, string Status);
public sealed record ProjectListResult(Guid ProjectId, string Name, string Status);
The public interface uses only primitive types and records in its signature. No domain types, no EF Core types -- nothing that would force consumers to take on extra dependencies. This is a key discipline when you want to create modular monolith .NET applications that can be refactored independently.
The implementation wires it all together:
using MediatR;
using Microsoft.EntityFrameworkCore;
using Projects.Domain;
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);
}
public async Task<GetProjectResult?> GetProjectAsync(
Guid projectId, CancellationToken cancellationToken = default)
{
var project = await _dbContext.Projects
.FirstOrDefaultAsync(p => p.Id == projectId, cancellationToken);
return project == null
? null
: new GetProjectResult(
project.Id, project.Name,
project.Description, project.Status.ToString());
}
public async Task<IReadOnlyList<ProjectListResult>> ListProjectsAsync(
CancellationToken cancellationToken = default)
{
return await _dbContext.Projects
.Select(p => new ProjectListResult(p.Id, p.Name, p.Status.ToString()))
.ToListAsync(cancellationToken);
}
}
The IPublisher injection is from MediatR. After saving, the module publishes a ProjectCreatedEvent -- this is the cross-module signal that other modules (like Notifications) can react to without the Projects module knowing anything about them. If you want to understand the mechanics of that publish/subscribe relationship, Observer vs Mediator Pattern in C# explains how the two approaches differ and when MediatR's IPublisher fits best.
Step 4: Implement the Infrastructure Layer
The Infrastructure layer provides EF Core implementations of the interfaces defined in Application. It knows about ProjectsDbContext and is responsible for registering everything with the DI container.
// ProjectsDbContext.cs
using Microsoft.EntityFrameworkCore;
using Projects.Application;
using Projects.Domain;
namespace Projects.Infrastructure;
public sealed class ProjectsDbContext : DbContext, IProjectsDbContext
{
DbSet<Project> IProjectsDbContext.Projects => Set<Project>();
public ProjectsDbContext(DbContextOptions<ProjectsDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(1000);
entity.Property(e => e.Status).IsRequired();
entity.Property(e => e.CreatedAt).IsRequired();
});
}
}
The DbSet<Project> is exposed only through the explicit interface implementation. This keeps ProjectsDbContext from leaking the entity outside of what the Application interface allows.
The registration extension method is the module's public surface for the DI container:
// ProjectsModuleExtensions.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Projects.Application;
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 AddProjectsModule extension method is the only thing the Host project needs to call. Everything inside the module -- the DbContext, the domain types, the module implementation -- stays hidden. Understanding how service lifetimes interact with scoped registrations like this is worth reviewing; the Singleton Design Pattern in C# guide covers the underlying lifetime concepts that inform this design decision.
Step 5: Define Integration Events in Contracts
The Contracts project is a thin assembly that contains only integration events. These are the signals a module broadcasts when something meaningful happens. Any module that wants to react to a ProjectCreatedEvent only needs a reference to Projects.Contracts -- not Projects.Application, not Projects.Domain.
// ProjectCreatedEvent.cs
using MediatR;
namespace Projects.Contracts;
public sealed record ProjectCreatedEvent(
Guid ProjectId,
string Name,
string Description) : INotification;
That's the entire file. Keep Contracts projects lean -- their job is to act as a shared vocabulary between modules, not to hold logic. Referencing INotification from MediatR is the only dependency this project needs.
Step 6: Create the Host and Wire Everything Together
The Host (Program.cs) is the composition root. It knows about all modules so it can register them, but it should contain no business logic. Here's the actual Program.cs from the reference app:
using Microsoft.EntityFrameworkCore;
using Projects.Application;
using Projects.Infrastructure;
using Tasks.Application;
using Tasks.Infrastructure;
using Users.Application;
using Users.Infrastructure;
using Notifications.Application;
using Notifications.Infrastructure;
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();
// Ensure databases exist on startup
using (var scope = app.Services.CreateScope())
{
var projectsDb = scope.ServiceProvider.GetRequiredService<ProjectsDbContext>();
await projectsDb.Database.EnsureCreatedAsync();
var tasksDb = scope.ServiceProvider.GetRequiredService<TasksDbContext>();
await tasksDb.Database.EnsureCreatedAsync();
}
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.MapGet("/projects/{id:guid}", async (Guid id, IProjectsModule projectsModule) =>
{
var result = await projectsModule.GetProjectAsync(id);
return result == null ? Results.NotFound() : Results.Ok(result);
});
app.MapGet("/projects", async (IProjectsModule projectsModule) =>
{
var result = await projectsModule.ListProjectsAsync();
return Results.Ok(result);
});
app.Run();
public partial class Program { }
public record CreateProjectRequest(string Name, string Description);
Each module call looks identical from the host's perspective: call the extension method, inject the interface. The host never reaches into module internals. If you later want to swap the SQLite database for PostgreSQL, you change one line inside AddProjectsModule -- nothing in the host changes.
When thinking about how to structure background work alongside module registration -- for example, scheduled jobs that cross module boundaries -- Hosted Services with Needlr covers lifecycle management patterns that integrate well with this style of module registration.
Step 7: Add a Second Module
The real test of a modular monolith tutorial is whether the pattern holds for the second module. Here's the key rule: Tasks.Application must NOT reference Projects.Application. It can reference Projects.Contracts if it needs to handle ProjectCreatedEvent, but the application layers are fully isolated from each other.
The ITasksModule interface follows the exact same shape as IProjectsModule:
namespace Tasks.Application;
public interface ITasksModule
{
Task<CreateTaskResult> CreateTaskAsync(
Guid projectId, string title, string description,
CancellationToken cancellationToken = default);
Task<AssignTaskResult> AssignTaskAsync(
Guid taskId, Guid userId,
CancellationToken cancellationToken = default);
Task<CompleteTaskResult> CompleteTaskAsync(
Guid taskId,
CancellationToken cancellationToken = default);
Task<GetTaskResult?> GetTaskAsync(
Guid taskId,
CancellationToken cancellationToken = default);
}
public sealed record CreateTaskResult(Guid TaskId, Guid ProjectId, string Title);
public sealed record AssignTaskResult(Guid TaskId, Guid UserId, bool Success);
public sealed record CompleteTaskResult(Guid TaskId, bool Success);
public sealed record GetTaskResult(
Guid TaskId, Guid ProjectId, string Title,
string Status, Guid? AssignedUserId);
The TaskItem domain entity also mirrors the same factory pattern used in Project:
namespace Tasks.Domain;
internal sealed class TaskItem
{
public Guid Id { get; private set; }
public Guid ProjectId { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Description { get; private set; } = string.Empty;
public TaskStatus Status { get; private set; }
public Guid? AssignedUserId { get; private set; }
public DateTime CreatedAt { get; private set; }
private TaskItem() { }
public static TaskItem Create(Guid projectId, string title, string description)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Task title cannot be empty", nameof(title));
if (projectId == Guid.Empty)
throw new ArgumentException("Project ID is required", nameof(projectId));
return new TaskItem
{
Id = Guid.NewGuid(),
ProjectId = projectId,
Title = title,
Description = description ?? string.Empty,
Status = TaskStatus.Todo,
CreatedAt = DateTime.UtcNow
};
}
public void Assign(Guid userId)
{
if (userId == Guid.Empty)
throw new ArgumentException("User ID is required", nameof(userId));
AssignedUserId = userId;
if (Status == TaskStatus.Todo)
Status = TaskStatus.InProgress;
}
public void Complete() => Status = TaskStatus.Completed;
}
And the registration extension is identical in shape to AddProjectsModule:
public static IServiceCollection AddTasksModule(
this IServiceCollection services, string connectionString)
{
services.AddDbContext<TasksDbContext>(options =>
options.UseSqlite(connectionString));
services.AddScoped<ITasksDbContext>(
sp => sp.GetRequiredService<TasksDbContext>());
services.AddScoped<ITasksModule, TasksModule>();
return services;
}
Once you see the Tasks module, the pattern is obvious: every module is a self-contained vertical slice with its own storage, its own interface, and its own registration method. No shared EF Core context, no shared service, no shared entity. When you want to build modular monolith C# applications that stay maintainable over years, this discipline pays off massively.
Step 8: Cross-Module Communication
Cross-module communication happens through Contracts events and MediatR's IPublisher. When TasksModule.AssignTaskAsync runs, it publishes a TaskAssignedEvent:
// 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;
The Notifications module listens for that event without knowing anything about the Tasks module's internals. It only references Tasks.Contracts:
// Notifications.Application/Handlers/EventHandlers.cs
using MediatR;
using Notifications.Application;
using Tasks.Contracts;
namespace Notifications.Application.Handlers;
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 decoupling here is complete. Tasks publishes an event -- it has no idea whether anything is listening. Notifications reacts to an event -- it has no idea where it came from. This publish/subscribe flow is the Observer design pattern running through MediatR as the mediator. Understanding that distinction matters when you're deciding how to structure cross-module signals in your own application.
Similarly, ProjectCreatedEventHandler in the Notifications module reacts to ProjectCreatedEvent from Projects.Contracts, following the same shape. One module fires. Another reacts. No direct coupling between them.
Testing Your Work
Integration tests are the most practical way to verify that the cross-module event flow actually works end to end. The reference app uses WebApplicationFactory<Program> from Microsoft.AspNetCore.Mvc.Testing:
public class TaskAssignmentIntegrationTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public TaskAssignmentIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task AssignTask_TriggersNotificationHandler_CrossModuleEventFlow()
{
var client = _factory.CreateClient();
// Create a user
var userResponse = await client.PostAsJsonAsync(
"/users", new { Name = "John Doe", Email = "[email protected]" });
userResponse.EnsureSuccessStatusCode();
var userResult = await userResponse.Content
.ReadFromJsonAsync<UserResult>();
var userId = userResult!.UserId;
// Create a project
var projectResponse = await client.PostAsJsonAsync(
"/projects", new { Name = "Test Project", Description = "Test" });
var projectResult = await projectResponse.Content
.ReadFromJsonAsync<ProjectResult>();
var projectId = projectResult!.ProjectId;
// Create and assign a task
var taskResponse = await client.PostAsJsonAsync(
"/tasks",
new { ProjectId = projectId, Title = "Test Task", Description = "Test" });
var taskResult = await taskResponse.Content
.ReadFromJsonAsync<TaskResult>();
var taskId = taskResult!.TaskId;
await client.PostAsJsonAsync(
$"/tasks/{taskId}/assign", new { UserId = userId });
// Verify notification was triggered cross-module
var notifResponse = await client.GetAsync("/notifications");
var notifications = await notifResponse.Content
.ReadFromJsonAsync<List<string>>();
Assert.NotNull(notifications);
Assert.Contains(notifications, n => n.Contains("Task assigned"));
Assert.Contains(notifications, n => n.Contains("Test Task"));
}
}
This test calls three different modules -- Users, Projects, Tasks -- through their HTTP endpoints, then verifies that the Notifications module received the cross-module event. No mocking, no faking -- this runs the real application pipeline. That's exactly the kind of test worth writing when you're first getting a modular monolith step by step up and running.
FAQ
What is the difference between a modular monolith and microservices?
A modular monolith is a single deployable unit with well-defined internal module boundaries. Microservices are separate deployable processes that communicate over a network. A modular monolith avoids distributed systems complexity (network failures, serialization, distributed transactions) while still enforcing the bounded-context discipline that makes code maintainable.
Can modules share a database in a modular monolith?
They can, but the reference app gives each module its own database file to enforce physical isolation. If you share a database, make sure each module only accesses its own tables -- never query another module's tables directly. Use integration events through Contracts to react to state changes in other modules.
How do I build modular monolith C# projects without MediatR?
MediatR makes in-process event publishing easy but it's not required. You can implement a simple IEventPublisher interface backed by a dictionary of registered handlers, or use the Observer design pattern directly. The key constraint is that Publishers must not reference Subscriber assemblies.
Should module Application layers reference each other?
No. This is the most important rule in this modular monolith tutorial. If Tasks.Application references Projects.Application, you've created a hidden coupling that defeats the purpose of modularization. Cross-module dependencies belong in the Contracts layer -- light events only, no behavior.
How do I handle transactions that span multiple modules?
Avoid them if at all possible. Design your modules so each operation is self-contained. When you absolutely need cross-module consistency, use a saga or process manager pattern built on integration events rather than database transactions. The Strategy Design Pattern in C# is useful here for building pluggable compensating action strategies for saga implementations.
When should I split a module into a microservice?
When a specific module needs to scale independently, has different deployment cadences, or needs to be owned by a separate team. A modular monolith makes that migration straightforward because each module already has clean boundaries, its own persistence layer, and communicates only through Contracts events.
How do I handle authorization across modules?
Keep authorization concerns in the Host or a dedicated cross-cutting module. Don't let authorization logic bleed into Domain or Application layers. One practical approach: inject a thin ICurrentUser interface into module implementations and resolve it via a middleware-set ambient context. The Decorator Design Pattern in C# is ideal for wrapping module interface implementations with cross-cutting concerns like authorization without modifying the core logic.
Conclusion
You now have a working blueprint to build modular monolith C# applications from scratch in .NET 9. The Projects module gave you the full four-layer pattern -- Domain, Application, Infrastructure, and Contracts. The Tasks module showed that the same structure scales to a second module without any shortcuts. And the Notifications module demonstrated how cross-module communication works through events without direct coupling.
The core discipline is enforced at the project reference level, not just by convention. When Application layers can't import each other's types, accidental coupling becomes a compiler error rather than a code review comment. That's the kind of guardrail that holds up across a growing team.
If you want to create modular monolith .NET projects that stay maintainable as they grow, start with this structure, keep Contracts lean, and let integration tests validate your cross-module event flows. The architecture will thank you later.

