
Modular Monolith vs Microservices: How to Decide in .NET
A platform team splits a .NET application into eleven services in a quarter. A year later every release still ships all eleven together, because six of them read the same Orders tables and a schema change in one breaks the rest. The team has paid for a distributed system and kept the coupling of a monolith. The modular monolith vs microservices decision is usually framed as deployment granularity. The useful framing: both architectures need the same module boundaries, and the real choice is which mechanism enforces them. A modular monolith enforces boundaries with the compiler, the database permission model and architecture tests inside one deployable. Microservices enforce them with the network, at the price of distributed failure modes and a second operational surface. What follows: the comparison, the three boundaries a .NET modular monolith has to enforce mechanically, when a module should leave, and how to extract it.
Modular Monolith vs Microservices: What Is the Difference?
A modular monolith is one deployable process whose code is divided into modules with explicit public contracts and private data, where a module may only call another module through its contract. Microservices put each module in its own process with its own database, so every cross-module call crosses the network. What differs is who stops a developer from crossing the boundary: a project reference graph and a failing architecture test in the monolith, a different database server in microservices. In-process calls can share a transaction, so a modular monolith can keep the Orders and Billing writes atomic; a network boundary needs sagas or outbox-driven eventual consistency for the same operation.
Dimension | Modular monolith | Microservices |
|---|---|---|
Deployment unit | One process, one release, scaled as a whole | One process per service, released and scaled independently |
Boundary enforced by | Project references, architecture tests, per-module schema permissions | Separate databases, the network, API contracts |
What a wrong boundary costs | A refactor inside one codebase | A contract change across two teams and two deployments |
Operational surface | One pipeline, one log stream, one on-call rotation | N pipelines, distributed tracing, per-service alerting |
The last two rows decide most cases for mid-size teams. Martin Fowler's 2015 observation still holds: almost all the successful microservice stories started with a monolith that got too big and was broken up, and systems built as microservices from day one often ended in trouble because nobody knows the right boundaries before the domain has run in production.
Why the Boundary Has to Be Enforced, Not Agreed
The plain monolith fails for a specific reason: its module boundaries live in a design document and a folder convention, and both decay under delivery pressure. The first time Billing needs an Orders field, the quickest path is a direct query against the Orders table, and once it works nobody removes it. Shopify documented this in 2019, when it chose a modular monolith over microservices and found that boundaries had to be measured by tooling in CI, not written down; enforcement was still future work then. By 2020 the monolith had 37 components and a tool that rejects pull requests breaking the dependency graph or a component's encapsulation. So the CTO's question is "which enforcement mechanism can this team sustain". Inside one process it is cheap and local; across processes it needs platform work, observability and an on-call model, which is why a distributed systems architecture that holds up is mostly operating discipline.
Three Boundaries a .NET Modular Monolith Has to Enforce
A modular monolith earns its name only if three boundaries are enforced by something that fails a build or a request, not by review comments: the code dependency graph, data ownership, and the communication path between modules. Each one below covers what it is, when it is not worth enforcing, the mistake teams make with it, and what breaks when it fails.
1. The code dependency graph
The code boundary means a module's internals are unreachable from other modules at compile time, and only a small Contracts project is shared. In .NET the cheapest enforcement is the project reference graph itself: Orders references Billing.Contracts and never Billing. Project references matter because a convention a developer can break with one using statement will be broken, while a missing reference is a compile error no deadline can argue with.
<!-- Modules/Orders/Orders.csproj -->
<ItemGroup>
<ProjectReference Include="..\..\Billing\Billing.Contracts\Billing.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Kernel\Shared.Kernel.csproj" />
<!-- No reference to Billing.csproj -->
</ItemGroup>Project references stop direct references but not indirect ones: a module type that leaks into the shared kernel is reachable by everyone, and an unwritten dependency set drifts. Two architecture tests close those gaps, one on the shared kernel and one allow-list per module. Reflection-based DI resolution is invisible to static analysis and needs a startup test that inspects the container's registrations. The example uses NetArchTest.Rules, whose last release (1.3.2, May 2021) targets .NET Standard 2.0 and runs on current .NET through compatibility rather than active support; ArchUnitNET is the maintained alternative with the same rule shape.
[Fact]
public void Shared_kernel_contains_no_module_types()
{
var result = Types.InAssembly(typeof(Shared.Kernel.Entity).Assembly)
.ShouldNot()
.HaveDependencyOnAny("Orders", "Billing", "Inventory")
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames));
}
[Fact]
public void Orders_depends_only_on_its_allow_list()
{
var result = Types.InAssembly(typeof(Orders.OrdersModule).Assembly)
.Should()
.OnlyHaveDependenciesOn("System", "Microsoft", "Orders",
"Billing.Contracts", "Inventory.Contracts", "Shared.Kernel")
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames));
}This boundary is not worth enforcing between parts of the system that change together and are owned by the same two people; eight modules with eight Contracts projects in a 20,000-line application is ceremony without protection. The common mistake is the shared project named Common or Core that accumulates entities and repositories from every module: every module reaches everything through it, and the architecture test passes because the dependency is on Common, not on Billing. When the code boundary fails, the consequence shows up at extraction time: a module that was supposed to leave the process depends on four others, and the planned two-week extraction becomes a six-month untangling that competes with feature work.
2. Data ownership per module
Data ownership means each module has a schema that only it reads and writes, through its own DbContext, with no foreign keys crossing schemas. It matters more than the code boundary because data coupling is what makes extraction expensive: a cross-module join is a SQL statement today and a distributed query tomorrow. In EF Core the module sets its default schema and keeps its migration history in that schema, so its migration state travels with its tables when extracted.
// Modules/Orders/Persistence/OrdersDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
}
// Host/Program.cs
builder.Services.AddDbContext<OrdersDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("Orders"),
sql => sql.MigrationsHistoryTable("__EFMigrationsHistory", "orders")));The per-context history table is an opt-in on the EF Core SQL Server provider; by default every context shares one unqualified __EFMigrationsHistory table that lands in the connecting login's default schema, usually dbo. The enforcement step is two logins per module with schema permissions granted only on its own schema: a runtime login that can only read and write data, and a deployment login that can change the schema, which is the split Microsoft's guidance on applying migrations recommends. A raw SQL query from Billing into orders.Orders then fails with a permission error in the first test run instead of in production six months later.
CREATE USER orders_app FOR LOGIN orders_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::orders TO orders_app;
CREATE USER orders_deploy FOR LOGIN orders_deploy;
GRANT ALTER, SELECT, INSERT, UPDATE, DELETE ON SCHEMA::orders TO orders_deploy;
GRANT CREATE TABLE TO orders_deploy;
-- Neither login has any grant on SCHEMA::billing or SCHEMA::inventory.Migrations then run per module, as an EF Core migration bundle built from that module's DbContext and executed under the deployment login before the application starts. Per-schema ownership is not the right call for genuinely shared reference data (countries, currencies) that every module reads and nobody writes at runtime; expose it through one module's contract or record it as a documented exception. The mistake teams make is the "temporary" cross-schema view that lets Billing read Orders rows because the contract did not expose a field yet; it is invisible to the architecture test and turns two modules back into one. When data ownership fails, the consequence is the opening paragraph: the schema cannot move to its own database without a coordinated change in every module that touched it, so the modular monolith can never become anything else.
3. Communication through contracts and events
The communication boundary means a module reacts to other modules through two channels only: a synchronous call to a public interface in the other module's Contracts project, or an integration event it subscribes to. It matters because this is the seam extraction later replaces with the network. If Billing invoices when it receives OrderPlaced rather than when Orders calls _billing.CreateInvoice(), moving Billing out of the process changes the transport, not the business logic.
// Modules/Orders/Orders.Contracts/Events/OrderPlaced.cs
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId,
decimal Total, string Currency, DateTimeOffset PlacedAt);
// Modules/Billing/Application/Handlers/CreateInvoiceOnOrderPlaced.cs
internal sealed class CreateInvoiceOnOrderPlaced(IInvoiceService invoices)
: IIntegrationEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced e, CancellationToken ct) =>
invoices.CreateDraftAsync(e.OrderId, e.Total, e.Currency, ct);
}Inside one process the dispatcher is small: Orders writes the event to an orders.OutboxMessages table in the same transaction as the order, and a hosted service reads the outbox and invokes the handlers. The outbox keeps the event from being lost when a handler throws, and it is the same table a broker publisher reads after extraction. Many .NET teams reach for MediatR here. Be precise about the cost: from version 13 (July 2025) MediatR is commercially licensed per developer, and the free Community license covers only organizations under 5 million dollars in annual gross revenue that have never raised more than 10 million dollars in outside capital, excluding government bodies and universities; it still needs a registered key, a missing key only logs warnings, and version 12 and earlier stay under their original Apache 2.0 terms. For an enterprise codebase that is a procurement decision.
Events are the wrong channel when the caller needs the answer to continue, such as checking a credit limit before accepting the order; that is a synchronous contract call, and pretending it is an event produces a chain of compensations nobody can follow. The common mistake is publishing domain entities as events: OrderPlaced carrying the whole Order aggregate couples every subscriber to Orders' internal model, and the first change to that model breaks Billing. When this boundary fails, the consequence appears during extraction: handlers that were supposed to move with Billing call back into Orders synchronously, and the extracted service needs a round trip to the monolith on every request, slower and less reliable than the method call it replaced.
When Microservices Are the Right Call
Microservices are the right call when a module has a concrete reason to be in a different process, and the team can carry the operational cost of that process. One module needs ten times the compute of the rest, and scaling the whole process means paying for nine idle copies of everything else. One module has a release cadence or a compliance regime the rest cannot share, such as payments under a separate audit scope. One module must keep serving when another is down. Or one module is owned by a team in another time zone and lockstep releases cost more than the network would.
Each reason comes with its bill: per-service capacity planning, backward-compatible contracts, callers that handle timeouts and partial failure, and an orchestrator with centralized logging and tracing before the first incident. Choosing between Docker Swarm and Kubernetes comes before there is a single service to run. The Helvetic car rental platform in Brain Space's portfolio runs more than ten interconnected Azure microservices on .NET 8 and Docker Swarm under a 99.9% uptime SLA, with the same team owning development and DevOps. A team that cannot run the platform should not split it. Microservices are the wrong call for a system whose boundaries are still moving, for a team without a platform or on-call model, and for a monolith that is slow because of a missing index rather than a missing process boundary.
Extracting a Module Without a Rewrite
If the three boundaries were enforced, extraction is a transport change, and the data step is the one most guides skip.
Prove isolation with a query, not a belief: list every foreign key and view that references the module's schema from outside it and remove them first, replacing each with an event-carried copy of the needed field.
Move the schema to its own database while the module is still in the monolith, by changing its connection string. Anything that breaks here is a hidden dependency found while fixing it is still a one-codebase change.
Swap the transport behind the contract: the in-process
IBillingApigets an HTTP implementation with explicit timeouts and a retry budget, and the outbox dispatcher starts publishing to a broker. Handler code does not change.Deploy the service beside the monolith with the in-process implementation still registered behind a flag, so rollback is configuration.
Delete the module only after the service has carried production load through a release cycle.
Teams that reverse this order stand up the service first and discover the data coupling second, ending with a service that reads the monolith's database over the network: the distributed monolith with an extra hop.
Common Mistakes in the Modular Monolith vs Microservices Decision
Copying companies whose constraints are not yours. Independent scaling of 37 components matters at Shopify's traffic; at a mid-size platform it solves a problem that does not exist yet, at a cost paid every day.
Building the modular monolith as a waiting room for microservices, designing every contract for the network when most modules will never leave.
Extracting too early, on a boundary not yet validated by a year of real changes. A wrong boundary inside one process is a refactor; across two services it is a contract migration two teams coordinate.
Teams modernizing a legacy application should keep this decision separate from the runtime move: migrating from .NET Framework incrementally has its own risks, and its end state is a well-enforced modular monolith, not a service mesh. Brain Space's software development and cloud engineering services cover this sequence, from architecture tests to running the extracted services.
The Decision Rule
Choose the boundary mechanism the team can enforce this quarter, not the architecture it hopes to deserve next year. For a .NET platform that means a modular monolith with compile-time module isolation, one schema and one runtime login per module, an outbox-backed event path between modules, and an architecture test that fails the build on the first violation. Run it in production until a specific module shows a measured reason to leave: compute it cannot share, a release or compliance regime it cannot share, a failure it must survive alone, or a team that owns it end to end. Extract that module and leave the rest where it is. That system holds the option value microservices promise, and has paid for it only where it was earned.