The modular monolith is a legitimate destination
One deployable with walls the compiler enforces. Not a stage on the way to microservices, but a shape a system can stay in for a decade.
In February 2011 I drew a diagram with six boxes on it and called them services, which in 2011 meant six ASP.NET applications talking over WCF. Two of them shipped. The other four stayed on the drawing for about a year, and then I rubbed them out. What we built instead was one deployable with hard walls inside it, and it was still one deployable, still in service, long after I stopped being the person who touched it. The walls moved twice in that time, which is two more times than they would have moved if they had been network boundaries.
Nobody called that a modular monolith, because the phrase did not exist. What I wrote at the top of the sheet was "a well layered application", and I wrote it slightly apologetically, because the fashionable answer was already to give each piece its own address. So this sheet started life as a defence rather than a proposal, addressed to people who wanted the drawing to have more boxes on it. Fourteen years later the argument has not changed much. Only the names of the things I am declining to do have changed.
A modular monolith is not a waiting room. It is a shape a system can stay in permanently and be well built. What makes it modular is not that it is small, and not that the folders have tidy names. It is that the boundaries inside it are real: they appear in the solution file, the compiler enforces them, and crossing one is expensive enough that nobody does it by accident on a Friday afternoon.
The thing people get backwards is which coupling hurts. Deployment coupling is annoying: one build, one release, everybody's change goes out together. Design coupling is fatal: a change to the pricing rules that breaks billing because both read the same entity. Splitting into services fixes the annoying one and does nothing at all for the fatal one. You can have a distributed system where every service reaches into every other service's database, and I have been shown two.
One deployable, many modules
The physical shape is a solution with a project per module and one host project that references them all. Each module owns its data, and by "owns" I mean a schema of its own in the same database, its own DbContext, its own migrations. Nothing outside the module has a type that maps to a table inside it. If billing wants the price of a plan, it asks the catalogue through a method call, the same way it would ask over HTTP, minus the serialisation and the retry policy.
Each module assembly exposes as little as it can get away with. One interface, sometimes two, plus the contract types those methods take and return. Everything else in the assembly is internal, which means the C# compiler will not let a sibling module see it even if a developer wants to, and developers do want to, at about eleven at night, when a deadline is close.
// Billing: the entire public surface of the module.
public interface IBillingModule
{
Task<InvoiceRunSummary> RunMonthAsync(Guid tenantId, YearMonth period, CancellationToken ct);
Task<InvoiceSummary?> FindAsync(Guid tenantId, Guid invoiceId, CancellationToken ct);
}
// Everything below this line is invisible outside the assembly.
internal sealed class BillingModule(BillingDbContext db, TimeProvider clock) : IBillingModule
{
// ...
}
The host wires the modules up and knows nothing about their insides. Each module ships one extension method, and the composition root is a list of calls to those methods. In 2011 the same job was a Castle Windsor installer per module and a host that ran them in a loop, which achieved the same separation with more ceremony and one more thing to get wrong on a rename.
// Billing/ServiceCollectionExtensions.cs (.NET 9)
public static class BillingModuleExtensions
{
public static IServiceCollection AddBilling(this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<BillingDbContext>(o =>
o.UseSqlServer(config.GetConnectionString("Sql"),
sql => sql.MigrationsHistoryTable("__Migrations", "billing")));
services.AddScoped<IBillingModule, BillingModule>();
return services;
}
}
The shared kernel stays small or it stops being a kernel
There is always a project in the middle that everybody references. On the product I look after now it is called Kernel and it holds nine types: a tenant identifier, a money value type, a result type, a clock abstraction, a domain event base, and four small interfaces. That is the whole thing. It has no DbContext, no entities, no HTTP client, and no folder called Helpers.
By 2021 that project had grown to about forty types, because a shared kernel is where things go when nobody wants to decide who owns them. A currency conversion service had ended up in there. So had a PDF renderer. The cut back to nine took a week and the rule we adopted afterwards is the one I would give anyone: a type belongs in the kernel only if two or more modules need it and no module would be a sensible owner. If a module would be a sensible owner, give it to that module and let the others ask.
Making the walls fail the build
Project references get you most of the way, and internal gets you the rest, but neither stops a module from taking a dependency on a sibling's public facade and quietly growing a web of them. We added an architecture test in 2022 after finding that the catalogue referenced billing, notifications and tenant admin, which meant it was not a module any more, it was the application.
[Fact]
public void Catalogue_depends_on_nothing_but_the_kernel()
{
var result = Types.InAssembly(typeof(ICatalogue).Assembly)
.Should()
.NotHaveDependencyOnAny("Billing", "Notifications", "TenantAdmin")
.GetResult();
Assert.True(result.IsSuccessful,
string.Join(", ", result.FailingTypeNames ?? []));
}
That test has failed four times in three years. Every failure was a developer solving a real problem in the fastest available way, and every one of the four turned into a five-minute conversation about where the code belonged. Five minutes is the correct cost. Code review was the wrong mechanism because reviewers approve things.
When I split, and when I do not
I have split a module out of a monolith exactly once. Notifications, in 2022, because a tenant with a very chatty webhook configuration was saturating the thread pool during the month end billing run and taking the whole process down with it. The evidence was a graph. The service that came out of it has one queue in front of it and does one thing.
The reasons I accept for splitting are: a measured difference in scaling profile, a legal or contractual requirement that some data lives elsewhere, a runtime dependency you do not want inside your process (a native library that leaks, a framework version you cannot adopt yet), and a team that genuinely cannot ship because of merge contention, which in my experience needs about twelve engineers on one codebase before it is true. The reasons I do not accept are the org chart, a desire for the word microservices to appear in the architecture document, and the belief that a distributed system is automatically better factored. Microsoft's own boundary guidance says the same thing in politer language: start coarse, because splitting a service in two is easier than reassembling four.
What I would draw today
One deployable, a project per module, a facade per module, a kernel of under a dozen types, and a test that fails the build when the graph goes wrong. If a module needs to leave, it leaves through the facade it already has, which is the actual payoff: the modular monolith is the cheapest possible option on a future split, and you buy it by doing nothing except keeping the walls honest while the product finds out what it is.
Drawn from
- Common web application architectureslearn.microsoft.com
- internal keyword (C# reference)learn.microsoft.com
- Identify microservice boundarieslearn.microsoft.com