In a previous series, “Architecting to Scale”, by Jeff Valore, he addresses the why and when of modular monolith thinking, but it’s explicitly language-agnostic and focused on scaling strategy. It would be beneficial to complement the series with a practical implementation of a modular monolith project and how one might create such a project in .NET.
Introduction
Jeff’s series goes into all of this in depth, but let’s review the highlights. There is a widespread assumption that when you are building a system, you have two options: Either you create a monolith, or you create a microservice. In practice, there is a lot of feeling that monoliths are “old” and bad and microservices are “modern”, and the question was not whether you would migrate to a microservices approach, but when.
There are a few reasons why this is false:
Reality is a spectrum. There is a wide gulf between the “one big deployable” approach and the “50 independent services”, and there are a huge range of architectures that fit across that range – modular monoliths, mini-services, service-oriented architecture, extracted workers monoliths, and so forth. Considering only the two extremes collapses all of that nuance.
The tradeoffs depend greatly on team size. Microservices were designed to solve problems that occur at a certain organizational and traffic scale, where teams are large enough that deployment coordination becomes a bottleneck. For most teams, the overhead involved in microservices (distributed tracing, deployments, network failure modes, eventual consistency, etc) costs more than it saves.
The monolith isn’t the problem – the unstructured monolith is. When people think about monoliths, they envision a codebase that is a tangled mess and nothing can be changed without breaking everything. That’s a boundaries problem, not a model problem. It can be solved without migrating your system to a distributed model.
Microservices don’t automatically give you good boundaries. A common failure mode is a “distributed monolith” – teams split their app into separate services, but keep all the implicit coupling intact. The result is all the complexity of microservices with none of the independence that is the whole point of a microservices system.
The modular monolith is a direct rebuttal to this false binary. You can enforce real domain boundaries within a single deployable, which gives you most of the benefits of a microservices design without the operational cost and overhead. And if you do eventually need to extract a service, the boundaries are already there.
So what is a Modular Monolith? It’s a single deployable unit, internally separated by domain boundaries. Those boundaries are not just in the folder structure, they are in the very layout and design of the projects of the solution and how everything is separated and organized.
Diagram: N-Tier vs. Vertical Slice vs. Modular Monolith
Project Structure
So what would a project like this look like in a .NET solution? Essentially, the way that it would break down is that each module that you’re adding to your solution would have its own project structure and that all code related to that module would reside within that folder structure.
Let’s walk through creating an example using the Ardalis Modulith template. First, let’s install the template in our system.
dotnet new install Ardalis.Modulith
Then we’ll create a sample solution from that template.
dotnet new modulith -n SchoolModulith --with-module Teachers
Next, let’s add a GradeBook module to our solution.
dotnet new modulith --add basic-module --with-name GradeBook --to SchoolModulith
This will create a new solution structure with a group of projects broken up into modules.
SchoolModulith/
├── SchoolModulith.sln
├── Directory.Build.props
│
├── Shared/
│ └── SchoolModulith.SharedKernel/
│ └── IRegisterModuleServices.cs
│
├── SchoolModulith.Web/
│ ├── Program.cs
│ └── ModuleRegistrationExtensions.cs
│
├── Teachers/
│ ├── SchoolModulith.Teachers/ ← Domain + Application + Infrastructure (all internal)
│ ├── SchoolModulith.Teachers.Contracts/ ← Public API surface (events, DTOs)
│ ├── SchoolModulith.Teachers.HttpModels/ ← HTTP client models (for future extraction)
│ └── SchoolModulith.Teachers.Tests/
│
└── GradeBook/
├── SchoolModulith.GradeBook/
├── SchoolModulith.GradeBook.Contracts/
├── SchoolModulith.GradeBook.HttpModels/
└── SchoolModulith.GradeBook.Tests/
We can see here a web project as our base project. It could be an API or console app or whatever. We also have a shared kernel project for code that applies across the entirety of the application.
The focus is 4 projects for our Teacher module: A core project, a contracts project, and HttpModels project, and a unit tests project. It could be structured differently. The key is that we have a single place where everything related to our Teachers module is grouped together. Any services, business logic, models, contracts, DTOs, or anything else related to teachers would go somewhere in this structure and our web project would reference these projects to implement any functionality related to Teachers.
If you’re a fan of Clean Architecture, you can see how this type of structure applies that approach at the module level, using the project structure itself to enforce the boundaries between concerns.
SchoolModulith.Teachers- This project contains everything from a Clean Architecture solution that would live in the Core, UseCases, and Infrastructure projects: Entities, aggregates, domain events, handlers, DbContexts, repositories, etc. For example,Teachershas its own DbContext separate from any other module. TheGradeBookmodule should never directly access teacher data. The critical enforcement mechanism is that almost everything in here is marked asinternalso it can’t be accessed from other modules.SchoolModulith.Teachers.Contracts- This project publicly surfaces all of our Teachers functionality - DTOs for cross-module communication, events that other modules might subscribe to, and any exposed service interfaces. The code inSchoolModulith.Teachersproject depends onContracts, but not the other way around. This is what sets the boundary. The outside world can see and reference theContractsproject, but NOT the core project.
Communication Between Modules
Modules don’t live in isolation. There are times when they will need to communicate with one another. Teachers will need access to data in grade books, and grade books might need to know about teachers, and so forth. How does this communication take place?
There are a number of approaches you could take, such as message buses like NServiceBus or command dispatchers like Mediatr. The key here is that modules must never reference another module’s internals directly. Cross-module access should go through some sort of external events process using the exposed Contracts for communicating with each other.
There are a number of reasons for this. First, the whole point of Modular Monolith and Clean Architecture is to have a clean separation of concerns. Another big reason, however, is future upgradability. If you do ever want (or need) to implement a microservices architecture, having your code separated in modules will make that much simpler. Pulling out that code into its own service will be a quick exercise, and therein lies a major benefit of this approach. You don’t have a mass of spaghetti code to pick apart. There’s no coupling to work through. You just break the module out on its own and go.
Examples
Let’s look at some code from our example to see how this all fits together in practice.
Shared Kernel - The Module Contract
Every module must implement this interface. The static abstract member means
the DI registration happens at the type level, not the instance level.
// Shared/SchoolModulith.SharedKernel/IRegisterModuleServices.cs
namespace SchoolModulith.SharedKernel;
public interface IRegisterModuleServices
{
static abstract void ConfigureServices(IServiceCollection services);
}
Web — Discovering and Wiring Modules
ModuleRegistrationExtensions.cs scans loaded assemblies for IRegisterModuleServices
implementations and calls each one. The host never needs to know which modules exist —
adding a new module project reference is enough. That makes it easy to add and remove modules as needed and appropriate without having to track down any hard-wired dependencies.
// SchoolModulith.Web/ModuleRegistrationExtensions.cs
namespace SchoolModulith.Web;
public static class ModuleRegistrationExtensions
{
public static void DiscoverAndRegisterModules(this IHostApplicationBuilder builder)
{
var moduleRegistrars = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(t => t.IsAssignableTo(typeof(IRegisterModuleServices))
&& !t.IsInterface
&& !t.IsAbstract);
foreach (var registrar in moduleRegistrars)
{
registrar.GetMethod(nameof(IRegisterModuleServices.ConfigureServices))!
.Invoke(null, [builder.Services]);
}
}
}
And add the call to DiscoverAndRegisterModules to our Program.cs.
// SchoolModulith.Web/Program.cs
using SchoolModulith.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFastEndpoints();
builder.Services.AddSwaggerDoc();
builder.DiscoverAndRegisterModules();
var app = builder.Build();
app.UseFastEndpoints();
app.UseSwaggerGen();
app.Run();
Modules — Internal Implementation
Now let’s look at how the various pieces of a typical module get put together. We’ll look at the code from our Teachers module as an example. As I mentioned before, there are a number of ways you can handle the cross-module communication. These examples make use of MediatR.
Service Registrar
Each module owns its DI wiring. Nothing outside the module can see the types being
registered — it only knows IRegisterModuleServices.
// Teachers/SchoolModulith.Teachers/TeachersModuleServiceRegistrar.cs
namespace SchoolModulith.Teachers;
public class TeachersModuleServiceRegistrar : IRegisterModuleServices
{
public static void ConfigureServices(IServiceCollection services)
{
services.AddScoped<ITeacherService, TeacherService>();
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(TeachersModuleServiceRegistrar).Assembly));
}
}
Enforcing Internal Visibility
AssemblyInfo.cs grants test access without making types public. Outside of the
test project, these types are invisible to other modules.
// Teachers/SchoolModulith.Teachers/AssemblyInfo.cs
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SchoolModulith.Teachers.Tests")]
Domain and Application Layer (all internal)
These types live inside the module project and cannot be referenced by GradeBook, Web, or any other module. This is Clean Architecture’s inner ring enforced by the C# compiler rather than project references.
// Teachers/SchoolModulith.Teachers/Teacher.cs
namespace SchoolModulith.Teachers;
internal class Teacher
{
public int Id { get; private set; }
public string FirstName { get; private set; } = string.Empty;
public string LastName { get; private set; } = string.Empty;
public string Subject { get; private set; } = string.Empty;
private Teacher() { }
internal static Teacher Create(string firstName, string lastName, string subject)
{
return new Teacher
{
FirstName = firstName,
LastName = lastName,
Subject = subject
};
}
public string FullName => $"{FirstName} {LastName}";
}
// Teachers/SchoolModulith.Teachers/ITeacherService.cs
namespace SchoolModulith.Teachers;
internal interface ITeacherService
{
Task<IEnumerable<TeacherSummary>> GetAllAsync(CancellationToken ct = default);
Task<TeacherSummary?> GetByIdAsync(int id, CancellationToken ct = default);
}
internal record TeacherSummary(int Id, string FullName, string Subject);
// Teachers/SchoolModulith.Teachers/TeacherService.cs
namespace SchoolModulith.Teachers;
internal class TeacherService() : ITeacherService
{
private static readonly List<Teacher> _teachers =
[
Teacher.Create("Ada", "Lovelace", "Mathematics"),
Teacher.Create("Grace", "Hopper", "Computer Science"),
];
public Task<IEnumerable<TeacherSummary>> GetAllAsync(CancellationToken ct = default)
{
var summaries = _teachers.Select(t => new TeacherSummary(t.Id, t.FullName, t.Subject));
return Task.FromResult(summaries);
}
public Task<TeacherSummary?> GetByIdAsync(int id, CancellationToken ct = default)
{
var teacher = _teachers.FirstOrDefault(t => t.Id == id);
var summary = teacher is null ? null : new TeacherSummary(teacher.Id, teacher.FullName, teacher.Subject);
return Task.FromResult(summary);
}
}
FastEndpoints (internal)
Endpoints live in the module but are discovered at startup by FastEndpoints scanning
all assemblies in the app. The module keeps them internal — no other module can
import or extend them directly.
// Teachers/SchoolModulith.Teachers/GetTeachersEndpoint.cs
namespace SchoolModulith.Teachers;
using SchoolModulith.Teachers.HttpModels;
internal class GetTeachersEndpoint(ITeacherService teacherService)
: EndpointWithoutRequest<IEnumerable<TeacherResponse>>
{
public override void Configure()
{
Get("/teachers");
AllowAnonymous();
Description(d => d.WithTags("Teachers"));
}
public override async Task HandleAsync(CancellationToken ct)
{
var teachers = await teacherService.GetAllAsync(ct);
await SendOkAsync(teachers.Select(t => new TeacherResponse(t.Id, t.FullName, t.Subject)), ct);
}
}
Teachers.HttpModels — Serialization Shapes
HttpModels hold the wire format — the DTO shapes that appear in Swagger and that a
typed HTTP client would use if this module were ever extracted into its own service.
These are public because they cross the HTTP boundary.
// Teachers/SchoolModulith.Teachers.HttpModels/TeacherResponse.cs
namespace SchoolModulith.Teachers.HttpModels;
public record TeacherResponse(int Id, string FullName, string Subject);
// Teachers/SchoolModulith.Teachers.HttpModels/ITeachersHttpClient.cs
namespace SchoolModulith.Teachers.HttpModels;
public interface ITeachersHttpClient
{
Task<IEnumerable<TeacherResponse>> GetTeachersAsync(CancellationToken ct = default);
Task<TeacherResponse?> GetTeacherByIdAsync(int id, CancellationToken ct = default);
}
Teachers.Contracts — Cross-Module Events
Contracts are the only types that other modules are allowed to depend on.
The dependency rule in the template enforces this: SchoolModulith.GradeBook
can reference SchoolModulith.Teachers.Contracts, but never
SchoolModulith.Teachers itself.
// Teachers/SchoolModulith.Teachers.Contracts/TeacherAssignedToClassEvent.cs
namespace SchoolModulith.Teachers.Contracts;
/// <summary>
/// Published by the Teachers module when a teacher is assigned to a class.
/// The GradeBook module subscribes to this event to initialize a grade record.
/// </summary>
public record TeacherAssignedToClassEvent(
int TeacherId,
string TeacherFullName,
int ClassId,
string ClassName) : INotification;
Publishing the event from inside the Teachers module
Now that we have defined all the internals and the contracts, we’ll look at how cross-module communication occurs. With a mediator and command dispatcher library like MediatR, a module will publish messages that another module will “handle”. We implement two pieces of code for each communique. The first is a publisher which will create a message and send it off into the “void” of our application. In MediatR, this is implemented via the Publish command.
// Teachers/SchoolModulith.Teachers/AssignTeacherToClassHandler.cs
namespace SchoolModulith.Teachers;
using SchoolModulith.Teachers.Contracts;
internal class AssignTeacherToClassHandler(IMediator mediator)
: IRequestHandler<AssignTeacherToClassCommand, Result>
{
public async Task<Result> Handle(AssignTeacherToClassCommand request, CancellationToken ct)
{
// ... domain logic ...
// Publish the event — GradeBook (and any other module) can subscribe via Contracts
await mediator.Publish(new TeacherAssignedToClassEvent(
request.TeacherId,
"Ada Lovelace",
request.ClassId,
"Year 10 Maths"), ct);
return Result.Success();
}
}
GradeBook Module — Consuming a Cross-Module Event
The second piece is a handler which will watch for messages of a certain type and process them. In MediatR, the INotificationHandler<T> interface is how we define this.
GradeBook references SchoolModulith.Teachers.Contracts (allowed) but never
SchoolModulith.Teachers (forbidden). This is the key boundary the architecture
enforces.
// GradeBook/SchoolModulith.GradeBook/TeacherAssignedHandler.cs
namespace SchoolModulith.GradeBook;
using SchoolModulith.Teachers.Contracts;
/// <summary>
/// Reacts to the Teachers module event to initialize a GradeBook for the assigned class.
/// Note the reference: Teachers.Contracts ✓ — never Teachers itself ✗.
/// </summary>
internal class TeacherAssignedHandler : INotificationHandler<TeacherAssignedToClassEvent>
{
public Task Handle(TeacherAssignedToClassEvent notification, CancellationToken ct)
{
// Initialize grade book for this class assignment
var gradeBook = GradeBook.InitializeFor(
notification.ClassId,
notification.ClassName,
notification.TeacherId);
// Persist, raise domain events, etc.
return Task.CompletedTask;
}
}
Our InitializeFor function:
// GradeBook/SchoolModulith.GradeBook/GradeBook.cs
namespace SchoolModulith.GradeBook;
internal class GradeBook
{
public int Id { get; private set; }
public int ClassId { get; private set; }
public string ClassName { get; private set; } = string.Empty;
public int AssignedTeacherId { get; private set; }
private GradeBook() { }
internal static GradeBook InitializeFor(int classId, string className, int teacherId)
{
return new GradeBook
{
ClassId = classId,
ClassName = className,
AssignedTeacherId = teacherId
};
}
}
Don’t forget to configure MediatR in the module.
// GradeBook/SchoolModulith.GradeBook/GradeBookModuleServiceRegistrar.cs
namespace SchoolModulith.GradeBook;
public class GradeBookModuleServiceRegistrar : IRegisterModuleServices
{
public static void ConfigureServices(IServiceCollection services)
{
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(GradeBookModuleServiceRegistrar).Assembly));
}
}
Testing and Enforcement
The template will create a base test class for each module. In this case there will be TeachersTypesShould.cs and GradeBookTypesShould.cs. These tests use a library called ArchUnitNET. ArchUnitNET is responsible for the enforcement of the internal policies. These tests act as a ratchet — they fail at build time if someone accidentally makes an internal type public or tries to reference something directly that it shouldn’t. For example, if GradeBook directly references Teachers internals.
// Teachers/SchoolModulith.Teachers.Tests/TeachersTypesShould.cs
namespace SchoolModulith.Teachers.Tests;
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnit;
using static ArchUnitNET.Fluent.ArchRuleDefinition;
public class TeachersTypesShould
{
private static readonly Architecture Architecture = new ArchLoader()
.LoadAssemblies(
typeof(TeachersModuleServiceRegistrar).Assembly,
typeof(SchoolModulith.Teachers.Contracts.TeacherAssignedToClassEvent).Assembly)
.Build();
[Fact]
public void HaveOnlyOnePublicTypeInMainAssembly_TheServiceRegistrar()
{
// Only TeachersModuleServiceRegistrar should be public in the main module project.
// Everything else — entities, handlers, endpoints, services — must be internal.
Types().That()
.ResideInAssembly(typeof(TeachersModuleServiceRegistrar).Assembly)
.And().ArePublic()
.Should().Be(typeof(TeachersModuleServiceRegistrar))
.Because("module internals must not leak outside the module boundary")
.Check(Architecture);
}
[Fact]
public void HaveAllContractTypesPublic()
{
// Contracts exist to be shared — every type in the Contracts project must be public.
Types().That()
.ResideInAssembly(typeof(SchoolModulith.Teachers.Contracts.TeacherAssignedToClassEvent).Assembly)
.Should().BePublic()
.Because("contract types are the module's explicit public API")
.Check(Architecture);
}
}
This allows us to enforce our separation of responsibilities via unit tests.
As an alternative to the unit test enforcement approach, there is a tool called NsDepCop, which is a static analysis tool that enforces namespace and assembly dependencies at compile time. It’s easy to install and configure for your application. You add a simple xml text file to your project that defines your dependency rules. Violations will be underlined in the code and reported as errors at build time like any other compiler errors and warnings.
Here’s an example NsDepCop config file taken from the Ardalis Clean Architecture template:
<?xml version="1.0" encoding="utf-8"?>
<NsDepCopConfig>
<Allowed From="*" To="*" />
<Disallowed From="MinimalClean.Architecture.Web.Domain.*"
To="MinimalClean.Architecture.Web.Infrastructure.*" />
<Disallowed From="MinimalClean.Architecture.Web.CartFeatures.*"
To="MinimalClean.Architecture.Web.Infrastructure.*" />
<Disallowed From="MinimalClean.Architecture.Web.ProductFeatures.*"
To="MinimalClean.Architecture.Web.Infrastructure.*" />
<Disallowed From="MinimalClean.Architecture.Web.Domain.OrderAggregate.*"
To="MinimalClean.Architecture.Web.Domain.CartAggregate.*" />
<AnalyzerConfig>
<IssueKind Name="IllegalDependency" Severity="Error" />
</AnalyzerConfig>
</NsDepCopConfig>
The Dependency Rule in Project References
The SchoolModulith.GradeBook.csproj project references look like this — the
compiler literally prevents the wrong dependencies from compiling:
<!-- GradeBook/SchoolModulith.GradeBook/SchoolModulith.GradeBook.csproj -->
<ItemGroup>
<!-- ✓ Allowed: shared kernel and other modules' Contracts -->
<ProjectReference Include="..\..\Shared\SchoolModulith.SharedKernel\SchoolModulith.SharedKernel.csproj" />
<ProjectReference Include="..\..\Teachers\SchoolModulith.Teachers.Contracts\SchoolModulith.Teachers.Contracts.csproj" />
</ItemGroup>
The following attempt at a reference would be a boundary violation — the template prevents it
<ItemGroup>
<ProjectReference Include="..\..\Teachers\SchoolModulith.Teachers\SchoolModulith.Teachers.csproj" />
</ItemGroup>
Another way in which the separation of responsibilities is enforced.
Clean Architecture Mapping Summary
Putting it all together, it looks something like this.
| Clean Architecture Layer | Modulith Location | Visibility |
|---|---|---|
| Domain (entities, value objects) | SchoolModulith.Teachers | internal |
| Application (use cases, handlers) | SchoolModulith.Teachers | internal |
| Infrastructure (repos, DbContext) | SchoolModulith.Teachers | internal |
| Presentation (endpoints) | SchoolModulith.Teachers | internal (discovered at runtime) |
| Published interface / ACL | SchoolModulith.Teachers.Contracts | public |
| HTTP wire format | SchoolModulith.Teachers.HttpModels | public |
| Host / composition root | SchoolModulith.Web | hosts all modules |
The key insight: in a traditional Clean Architecture solution, separate projects enforce
the dependency rule. In the modulith template, the module project boundary plus
internal access modifiers do the same job — and the ArchUnitNET tests verify it
doesn’t drift.
Microservices…. when?
When does it make sense to migrate a piece (or all) of our modular monolith into a microservice? There’s a lot of debate about when exactly microservices actually make sense for a team. While many feel that microservices are overused, there are times when that kind of an architecture makes sense. Here are a few general guidelines that can guide you along the way:
- When the engineering team size exceeds the size that can work simultaneously on the same project without stepping on each other’s toes
- When there are frequent merge conflicts
- When coordination for releases takes longer than the actual development work
- When there is large developer friction, such as when booting up the entire application locally takes several minutes or running unit tests takes forever
- When specific parts of the code need to be able to scale on a much different level than rest of the application
- When scaling the entire application leads to massive infrastructure waste
- When it makes sense to build a different portion of the application in a different programming language
- When a cascading failure due to a memory leak, bug, or crash in a minor portion of the system could bring down the entire application
- When an application-wide dependency on a single database leads to performance bottlenecks for the application
If, or when, microservices do make sense for your application, the migration to a microservices architecture will be much less painful if your monolith has been designed and implemented to prepare for that eventuality.
Conclusion
For most teams, and most applications, a modular monolith approach to your architecture has a lot of upsides. It’s clean, efficient, and well placed for future expandability and an eventual migration to microservices when that actually makes sense.

