The deck has eleven boxes on it. Each box is a service, each service has its own database, and the arrows between them are labelled "async." Someone asks how long it takes to add a field to a customer record, and the honest answer — four services, three teams, two release windows — is the reason the deck exists in the first place.
The debate that produces slides like this is almost always framed as microservices vs monolith, and in a .NET codebase that framing is the problem. It asks how many processes you should run. Nobody has a release-cadence problem, a scaling problem, or a blast-radius problem because of a process count. They have those problems because changing one part of the system requires rebuilding, retesting, and redeploying parts that have nothing to do with the change — and a process boundary is one of four available ways to fix that, the most expensive one, and the only one that also adds a network to every call you used to make for free.
This article separates what microservices actually sell you into four independent properties, shows which two of them need a process boundary and which two don't, and gives the .NET mechanics for getting the cheap ones: compiler-enforced module boundaries, schema-per-module data ownership, and independent scaling from a single deployable. It ends with the conditions under which extraction genuinely is the answer — because sometimes it is.
Why "Microservices vs Monolith" Is the Wrong Question for .NET Teams
Both words name a deployment topology, and a deployment topology is a consequence of an architecture rather than a description of one. "Monolith" in particular has stopped being a technical term. As Sam Newman — who wrote the book on the subject — put it at QCon London 2020, "monolith has become a replacement for the term we used to use, which was legacy," and the consequence is that people now treat any monolith as something to be removed. His position, from the author of Building Microservices (O'Reilly, 2nd ed. 2021), is blunter than most of the architecture decks that cite him: "the monolith is not the enemy," and "microservices should not be the default choice."
What he recommends focusing on instead is the useful part: "always remember the goal is independent deployability." That reframing does real work, because independent deployability is a property you can test for. Two modules are independently deployable if you can ship a change to one without rebuilding, retesting, or releasing the other. You can go and measure whether that's currently true. You cannot measure whether a system is "a monolith."
The cost of getting the framing wrong is well documented and consistently underestimated. Martin Fowler called it the microservice premium in 2015: microservices "introduce complexity on their own account," which "adds a premium to a project's cost and risk," and his conclusion was that you should not "even consider microservices unless you have a system that's too complex to manage as a monolith." Microsoft's own Azure Architecture Center guidance is no softer in its Challenges section — "each service is simpler, but the entire system as a whole is more complex" — and it lists a mature DevOps culture and a distributed-systems skill set as prerequisites rather than as nice-to-haves. Its companion readiness assessment exists specifically so teams evaluate whether they are ready "before adopting microservices," which is not advice a vendor writes about a technology it thinks is a safe default.
The Four Independences: What Microservices Actually Sell You
Microservices are usually adopted as a single decision, but they deliver four separable properties. Teams reliably need one of them, buy all four, and pay for all four forever. Separating them is the whole trick, because two of the four are available inside a single .NET process at a fraction of the cost.
| Independence | What it actually means | Needs a process boundary? | Cheapest .NET mechanism |
|---|---|---|---|
| Deployment | Ship a change to module A without rebuilding, retesting or releasing B | Yes | A separate service. There is no in-process substitute. |
| Technology | A uses a runtime, language or datastore the host can't host | Yes | A separate service. Rare, and easy to claim without evidence. |
| Scaling | A gets more CPU, memory or replicas than B | No | One artifact, several deployment roles — same image, different replica counts. |
| Failure | A's bad day doesn't become B's outage | No | Bulkheads, timeouts, circuit breakers — in-process resilience pipelines. |
The failure row is the one that surprises people, because fault isolation is the most commonly cited reason to split a system and it is the reason that survives scrutiny least well. A process boundary does contain certain failures — a memory leak, a runaway thread pool, a crash. It also creates failure modes that did not previously exist: timeouts, retries and the duplicate side effects they cause, partial failure where a call neither clearly succeeded nor clearly failed, and cascading saturation when a slow dependency consumes every caller's connections. An in-process call cannot be half-executed. A network call can, and designing for that is most of the premium.
So the decision collapses to a much smaller question than "microservices or monolith." It is: do we need independent deployability or a different technology stack, for a specific named module, right now? If yes, extract that module — one module, not the system. If no, every problem being discussed is reachable from inside one process, and the work is modularity rather than distribution.
The Module Boundary Ladder
Modularity in .NET is not one thing. It is four rungs of enforcement, each stronger and more expensive than the last, and the reason most architecture debates go sideways is that teams compare rung 0 to rung 3 as if nothing existed in between.
| Rung | Mechanism | Enforced by | Cost to adopt |
|---|---|---|---|
| 0 | Folders and namespaces | Nothing. Convention and code review — a using defeats it silently. |
Free |
| 1 | One project per module, internal types, no project reference between modules |
The C# compiler. Crossing the boundary is a build error. | About a day |
| 2 | Contracts-only references, plus architecture tests in CI | Compiler and CI. Catches the reference someone adds "just for now." | Two to three days |
| 3 | Separate process | The network. Enforces everything, and adds partial failure, versioning and eventual consistency. | Months, plus a permanent operational tax |
Most teams arguing about microservices are standing on rung 0. The complaint driving the argument — "everything is tangled, nobody can change anything safely" — is a rung-0 symptom with a rung-1 fix that takes about a day. Rung 3 also fixes it, in roughly the way that moving house fixes a noisy neighbour.
Simon Brown made the underlying point in his Modular Monoliths talk at GOTO Berlin 2018: "the design thinking required to create a good microservices architecture is the same as that needed to create a well structured monolith" — so "if you can't build a well-structured monolith, what makes you think microservices is the answer?" The boundaries are the hard part. Distribution just makes getting them wrong more expensive, because a bad boundary in one process is a refactor and a bad boundary across a network is a migration.
The Modular Monolith That Isn't
Rung 0 is where most "modular monoliths" actually live: one project, a
Modules folder, and a shared assembly in which every type can see every
other type. Here is what that permits, and it is worth noting that nothing in the
toolchain objects to any line of it.
// Modules/Orders/OrderService.cs — a "module" by folder name only.
public sealed class OrderService
{
private readonly OrdersDbContext _orders;
private readonly BillingDbContext _billing; // another module's context
public async Task<Guid> PlaceAsync(PlaceOrder cmd, CancellationToken ct)
{
var order = new Order(cmd.CustomerId, cmd.Lines);
_orders.Orders.Add(order);
// Reaching straight into Billing's entity model and tables.
_billing.Invoices.Add(new Invoice
{
OrderId = order.Id,
AmountUsd = order.Total,
Status = InvoiceStatus.Pending
});
await _orders.SaveChangesAsync(ct);
await _billing.SaveChangesAsync(ct); // separate transaction, no atomicity
return order.Id;
}
}
Three things are wrong, and the third is the expensive one. Orders now depends on
Billing's storage schema, so a Billing migration can break Orders at runtime with no
compile-time warning. The two SaveChangesAsync calls are separate
transactions, so a failure between them leaves an order with no invoice — the
classic dual-write problem, now inside a single process where everyone assumes
transactions still apply. And every call site like this is a future migration:
extracting Billing later means finding all of them, which is exactly the discovery
work that turns a three-month split into a two-year one.
Rung 1: Let the Compiler Hold the Boundary
The fix is structural rather than behavioural, and it relies on a language feature
that has been in C# since version 1. internal types are
accessible "only within files in the same assembly",
and referencing one from outside that assembly "is an error." The assembly, not the
folder, is the unit of encapsulation in .NET — so a module is a project, and its
boundary is the set of types it chooses to make public.
src/
Modules/
Billing.Contracts/ ← public. References nothing.
IBillingApi.cs
RaiseInvoice.cs
Billing/ ← references Billing.Contracts only
Internal/Invoice.cs internal
Internal/BillingDbContext.cs internal
BillingModule.cs public — the entire surface
Orders/ ← references Billing.Contracts. NOT Billing.
Host/ ← references every module, wires DI
The load-bearing line is the last one on Orders: there is no project reference from
Orders to Billing. That single omission is what makes the
previous code sample impossible to write — not discouraged, not flagged in review,
but rejected by dotnet build.
// Billing.Contracts — the whole public surface of the module.
// Deliberately free of EF Core types, so the contract survives extraction
// unchanged if this module ever becomes a separate service.
public interface IBillingApi
{
/// <summary>Raises an invoice for a placed order. Idempotent on
/// <paramref name="cmd"/>.OrderId — a repeat call returns the existing
/// invoice rather than creating a second one.</summary>
Task<InvoiceRef> RaiseInvoiceAsync(RaiseInvoice cmd, CancellationToken ct);
}
public sealed record RaiseInvoice(Guid OrderId, decimal Amount, string Currency);
public sealed record InvoiceRef(Guid InvoiceId, string Number);
Two details in that contract are doing quiet, important work. It exposes no EF Core types, so Billing can change its persistence entirely without recompiling Orders. And the idempotency guarantee is stated on the interface rather than assumed, because the day this module moves out of process, retries become the caller's normal behaviour — the same requirement covered in depth in idempotent message handling. Designing the in-process contract as if it were already remote is what makes the eventual move mechanical.
Inside the module, everything else stays sealed off:
// Billing — internal by default. Unreachable from Orders, because Orders
// holds no reference to this assembly.
internal sealed class Invoice { /* ... */ }
internal sealed class BillingDbContext(DbContextOptions<BillingDbContext> options)
: DbContext(options)
{
protected override void OnModelCreating(ModelBuilder b)
{
// One database, one schema per module. Billing's tables are its own.
b.HasDefaultSchema("billing");
b.ApplyConfigurationsFromAssembly(typeof(BillingDbContext).Assembly);
}
}
/// <summary>The module's façade — the only public type it exports.</summary>
public sealed class BillingModule : IBillingApi { /* ... */ }
HasDefaultSchema
gives each module its own namespace in a shared database, which is the right default
for a system that still runs in one process: private tables per module, but a single
connection, a single backup, and genuine ACID transactions when a use case actually
needs one. One registration detail is easy to miss and unpleasant to discover in
production — the default schema does not apply to the migrations history table, so
two modules will otherwise write their migration history to the same
__EFMigrationsHistory table in dbo and interfere with each
other's deployments:
// Host/Program.cs — each module owns its schema *and* its migration history.
services.AddDbContext<BillingDbContext>(o => o.UseSqlServer(
connectionString,
sql =>
{
sql.MigrationsHistoryTable("__EFMigrationsHistory", "billing");
sql.MigrationsAssembly(typeof(BillingDbContext).Assembly.FullName);
}));
Rung 2: The Test That Catches "Just for Now"
Rung 1 holds until someone needs a value from another module at 5pm on a Thursday and adds a project reference to get it. That reference is never removed, and by the time anyone notices, a dozen types depend on it. An architecture test makes that moment visible while it is still one line in a diff — this example uses ArchUnitNET:
private static readonly Architecture Arch = new ArchLoader()
.LoadAssemblies(
typeof(OrdersModule).Assembly,
typeof(BillingModule).Assembly)
.Build();
[Fact]
public void Orders_must_not_reach_into_Billing()
{
var orders = Types().That().ResideInAssembly(typeof(OrdersModule).Assembly);
var billing = Types().That().ResideInAssembly(typeof(BillingModule).Assembly);
orders.Should()
.NotDependOnAny(billing)
.Because("Orders may see Billing.Contracts, never Billing itself.")
.Check(Arch);
}
This test is redundant with the compiler on the day you write it, and that is the
point — it fails the moment the project reference that made it redundant gets added.
It also covers the case the compiler genuinely cannot: an
InternalsVisibleTo
attribute added for a legitimate reason — usually a test project — and then quietly
reused to reach a module's internals from production code.
Independent Scaling Without Independent Deployment
The most common genuine requirement behind a split is scaling: one module is CPU- heavy or bursty and the rest of the system is not. This does need its own process. It does not need its own release, and conflating those two is what turns a capacity problem into an architecture programme.
Read a role list from configuration and register only what that role needs. One artifact, one commit, several deployments:
// Host/Program.cs — one binary, deployed several ways. The role comes from
// configuration, so scaling a module never means releasing it separately.
var builder = WebApplication.CreateBuilder(args);
var roles = builder.Configuration.GetSection("Roles").Get<string[]>() ?? ["all"];
bool Enabled(string role) => roles.Contains("all") || roles.Contains(role);
// Modules always register: a role decides what *runs*, not what exists.
builder.Services.AddBillingModule(builder.Configuration);
builder.Services.AddOrdersModule(builder.Configuration);
if (Enabled("api"))
{
builder.Services.AddControllers();
}
// Statement generation is CPU-bound and bursty at month end. It scales on
// its own curve, from this same image, at a different replica count.
if (Enabled("statements"))
{
builder.Services.AddHostedService<StatementGenerationWorker>();
}
var app = builder.Build();
if (Enabled("api")) app.MapControllers();
app.MapHealthChecks("/health/live");
await app.RunAsync();
Two Azure Container Apps deployments now run from one image and one build:
Roles=api at three replicas, Roles=statements at twelve for
the last three days of the month. The statement worker has its own process, its own
memory ceiling, its own scaling rule and its own crash blast radius — and zero
network calls to Orders, zero contract versioning, zero eventual consistency, because
it is the same assembly. This is the pattern behind the
real-time financial statements
case study, where the expensive path was isolated and scaled without the system
around it being decomposed first.
The limits are worth stating plainly. Every role ships on the same release train, so a bad deploy affects all of them. Roles buy you independent scaling and independent failure; they do not buy independent deployability. If what you need is for the statement team to ship on Tuesdays while the API team ships continuously, this pattern will not give you that, and a separate service will.
When a Process Boundary Is Genuinely the Answer
Sometimes it is, and a framework that never says "split it" is just a different dogma. The conditions are specific and, importantly, most of them are countable.
| Condition | Answer | Why |
|---|---|---|
| Two teams' releases demonstrably block each other | Extract that module | Independent deployability is the one property with no in-process substitute |
| A module needs a runtime or datastore the host can't host | Extract that module | Polyglot requires a process. Name the capability, or it isn't this |
| One module needs different scaling, same release cadence | Add a deployment role | Separate process, single artifact — scale without the distributed premium |
| A module's failures take the whole host down | Bulkheads and timeouts first | A network boundary adds failure modes as well as removing them |
| Regulation requires separate data custody | Separate the datastore first | The requirement is usually about data location, not service topology |
| "It's slow" | Diagnose, don't distribute | Splitting adds network hops to a path that was already the constraint |
| "The codebase is a mess" | Climb the ladder | Modularity is the fix. Distribution is a much slower way to buy it |
| You can't name the modules — only "the monolith" | Neither. Find boundaries first | Splitting an unmodelled system produces a distributed monolith |
That last row is the failure mode worth naming, because it is the one that costs the most. A distributed monolith is a set of services that must be deployed together — every advantage of the monolith gone, every cost of microservices retained. It is the predictable result of drawing service boundaries before domain boundaries are understood, which is why Eric Evans' bounded contexts (Domain-Driven Design, Addison-Wesley, 2003) keep being cited in microservices literature: the boundary is a modelling result, not a deployment decision. If the modules cannot be named confidently enough to enforce them in a compiler, they are not ready to be enforced by a network.
When the conditions are met, extract one module at a time, starting with a leaf that few things depend on, and only after its boundary already exists in code. The move from rung 2 to rung 3 is then genuinely mechanical: the contract is already free of infrastructure types, the module already owns its schema, and the calls that must become remote are already funnelled through one façade. Cross-module calls that were in-process become messages — with the transport choice and delivery semantics that implies.
What This Framework Doesn't Solve
The strongest objection to everything above is that boundaries inside one process are
easier to erode than boundaries across a network, and it is a fair one. Stefan Tilkov
argued exactly this in
"Don't start with a monolith"
(2015), written as a direct response to Fowler's
MonolithFirst:
components in a single codebase end up sharing domain objects, persistence models and
transactions, and the very ease of refactoring in one IDE means developers never
establish real boundaries at all. That is a correct description of rung 0, and it is
why rung 1 matters — the argument is against convention-based modularity, not against
compiler-enforced modularity. But it remains true that InternalsVisibleTo,
a new project reference, or a shared "Common" assembly can each quietly undo the work,
and no test suite protects a team that decides the rule is inconvenient.
Second, the operational calculus has genuinely shifted, and pretending otherwise would be dishonest. Aspire — now polyglot and no longer carrying the ".NET" prefix — describes itself as "a code-first orchestration and observability layer for distributed applications," and it does solve the local-development friction that used to make multi-process topologies miserable: one command to run everything, service discovery without hardcoded connection strings, correlated logs and traces in one dashboard. That is real, and it lowers the entry cost. It does not touch the durable costs — partial failure, contract versioning across independently released services, eventual consistency where a transaction used to be. Those are properties of the network, and no orchestrator removes them.
Third, this is a technical framework and some splits are organizational. A module owned by an acquired company's team in another timezone, under a different compliance regime, may warrant its own service despite every technical indicator saying otherwise — Conway's law operating as a constraint rather than an observation. This framework will say "keep it in-process" and be technically right and practically wrong.
Finally, it assumes the problem has been diagnosed. "We need microservices" is a frequent response to a system that is merely slow, and slowness is attributable — usually to one constraint, often in one layer. If latency rather than release coupling started this conversation, that is a database-versus-application-layer attribution question first. Splitting a slow system into services adds network hops to the path that was already the bottleneck.
Frequently Asked Questions
- Is a modular monolith just a monolith with extra steps?
- The extra step is a compiler-enforced boundary, and it is the only part that matters. An ordinary monolith permits any type to reference any other type, so boundaries erode continuously and invisibly — each individual violation is reasonable and the aggregate is a system nobody can change safely. A modular monolith removes the permission rather than discouraging its use: modules are separate assemblies, their internals are internal, and no project reference exists between them. The cost is roughly a day of project restructuring plus the discipline to route cross-module calls through a contract. What you get back is the ability to reason about one module without loading the others into your head, and the option to extract a module later as a mechanical change rather than an archaeology project. If a team cannot hold that boundary in one process, adding a network between the modules does not supply the missing discipline — it just makes each violation an outage instead of a compile error.
- How do we scale one part of a .NET monolith without splitting it?
- Deploy the same binary several times with different roles enabled. Read a role list from configuration in Program.cs and register only the components that role needs — one deployment maps controllers and serves HTTP, another registers a hosted service and drains a queue, both built from the same image and the same commit. Scaling then becomes a replica count per deployment rather than an architecture change, so a month-end report generator can run at twelve replicas while the API runs at three. This separates independent scaling from independent deployability, which microservices bundle together and sell as one thing. The limits are real and worth stating: every role still ships on the same release train, and a bad deploy affects all of them, because there is exactly one artifact. If you need the report generator to ship on its own cadence rather than merely to scale on its own curve, roles will not give you that and a separate service will.
- When should we actually extract a module into a microservice?
- When two teams' releases block each other repeatedly, and you can show it rather than assert it. The evidence is countable: releases delayed waiting on another team's testing, rollbacks that reverted unrelated work, changes held back because the shared release window was too risky. If that number is meaningful and persistent, independent deployability is worth the distributed-systems premium for that module, because it is the one property you genuinely cannot get inside a single process. The second legitimate trigger is a hard technology requirement — a module that needs a runtime, language, or datastore the host cannot host. Everything else is usually solvable one rung down the ladder. Extract one module at a time, starting with a leaf that has few inbound dependencies, and only after its boundary already exists in code — extracting across a boundary that was never enforced is how teams end up with a distributed monolith, which has the operational cost of microservices and the coupling of a monolith.
- Does a modular monolith need a separate database per module?
- It needs separate schemas, not separate databases. One database with a schema per module and one DbContext per schema gives each module private tables while keeping a single connection string, a single backup, and — critically — real ACID transactions across modules when a use case genuinely needs one. Configure it with HasDefaultSchema in OnModelCreating, and give each context its own migrations history table, because the default schema does not apply to it and two modules will otherwise collide in the same history table. The rule that matters more than the physical layout is the access rule: no module reads another module's tables, and no entity carries a navigation property across a module boundary. Those two constraints are what make later extraction possible. Separate databases are what you move to when you extract, not something to pay for while everything still runs in one process.
- Does Aspire make microservices worth it for a small team?
- It lowers one cost honestly and leaves the expensive ones untouched. Aspire describes itself as a code-first orchestration and observability layer for distributed applications, and it genuinely solves the start five services in five terminals problem — local orchestration, service discovery without hardcoded connection strings, and a dashboard with correlated logs and traces. That is the friction most teams feel first, so it is easy to conclude the premium has gone away. It has not. The durable costs of distribution are partial failure, contract versioning across independently deployed services, eventual consistency where you previously had a transaction, and debugging a behavior that exists only in the interaction between services. No orchestration tool removes those, because they are consequences of the network, not of tooling. Aspire is a genuinely good reason to stop dreading a multi-process topology you already need. It is not a reason to adopt one you do not.
When to Bring in External Help
This decision is unusually hard to make from inside, because it is rarely settled on the merits. The engineers who want microservices are often right that something is badly wrong and wrong about which rung fixes it; the engineers defending the current system are often right that a split would be catastrophic and wrong that nothing needs to change. Both are reasoning from the same evidence, and the deciding vote usually goes to whoever is more senior rather than whoever is more correct.
A two-week Discovery Sprint answers the narrow question that actually governs the outcome: which modules exist, which rung each one is on, and whether any of them has a real independent-deployability requirement — measured against release history, not asserted in a meeting. The output is a ranked recommendation per module, which is almost never "split everything" and almost never "change nothing." For the broader assessment this fits inside, the CTO's guide to backend architecture reviews covers what a full review should cover and demand. If the conversation is really about replacing the system rather than dividing it, the rebuild vs refactor seam test is the prior question.