- Published on
A Modular Monolith Is Not a Compromise on Microservices — It Turns the Split into an Option You Can Exercise Later
- Authors

- Name
- Jack Qin
"Should we go microservices?" is almost always posed as a binary: either you let a monolith inevitably rot into a big ball of mud, or you shoulder the full operational burden of a distributed system. But that binary smuggles in a bundle nobody bothers to unpack: it assumes "module isolation" and "independent deployment" are two faces of the same thing — take both or take neither.
The entire reason a modular monolith exists is to break that bundle apart. It says: the benefits of module isolation (clean boundaries, independent evolution) are a real engineering need; the costs of independent deployment (distributed transactions, service discovery, network partitions) are an orthogonal operational expense. There's no law that says you must buy them together. Once you accept that they can be separated, "take the isolation now, leave the deployment split as an option" becomes an obvious move.
This post uses the backend of an environmental-monitoring platform as a worked example — an 11-module .NET 10 modular monolith — but the point isn't "what framework it uses." It's unpacking the decision space: why not microservices, what mechanically enforces module boundaries (rather than a gentlemen's agreement), and how exactly that "split option" is designed into the code so it can be exercised cheaply when the time comes.
The Decision Space: Four Options, Three Rejected, and Why
This is a backend for environmental dust monitoring and data management across mining operations. Early on it talked directly to a BaaS stack (PostgREST + RPC functions + Edge Functions). As permissions, reporting, scheduled jobs, and AI descriptions piled up, business logic scattered across database functions and the frontend, with no clear module boundaries and no unified error model. The new backend was meant to pull all of that back behind one complete .NET API layer.
What actually constrained the decision were a handful of hard conditions — not background, but scissors that cut options off directly:
- The team is just 1–2 people. Any distributed infrastructure that needs dedicated operations is out — not "we don't want it," but "we can't afford to keep it alive."
- The runtime is locked to .NET 10 LTS and C# 14, pinned in
global.json. We chose .NET 10 over 8/9 because .NET 8 LTS has only ~8 months of support left; getting forced into an upgrade right after launch isn't worth it. .NET 10 gives a 2.5-year window out to November 2028. - A single PostgreSQL 15.8 instance, already in place, not migrating. Modules are isolated by schema, not split into separate databases.
TreatWarningsAsErrorsis on globally, with nullable reference types enabled.
Run those scissors over the candidates and only one survives:
| Option | Verdict |
|---|---|
| Microservices | Rejected — for a 1–2 person team it's an enormous operational burden: distributed transactions, service discovery, network latency, all on your own back |
| Simple layered monolith | Rejected — no module boundaries; will eventually rot into a big ball of mud |
| Pure Clean Architecture | Rejected — over-abstracts modules of mixed complexity, forcing every module (including a few-field CRUD) into the same four layers |
| Modular monolith + WQW | Selected — module isolation, plus the deployment/debugging simplicity of a monolith |
Note why "pure Clean Architecture" is also rejected: it's an overshoot in the opposite direction from microservices. Microservices over-partition along the deployment axis; pure Clean over-partitions along the abstraction axis — forcing a three-field CRUD module to dutifully wear four layers. Both overshoots come from the same mistake: taking a discipline that holds in some contexts and applying it indiscriminately to all of them. The restraint of a modular monolith lies precisely in acknowledging that complexity is uneven — complex modules get full layering, simple CRUD gets a thin slice.
The whole thing is a "Web-Queue-Worker + modular monolith" combination, each pattern owning one concern:
flowchart TD
WQW[Web-Queue-Worker<br/>Load separation: sync HTTP vs async background]
WQW --> Api[Api Host]
WQW --> Worker[Worker Host]
Api --> Mono[Modular monolith<br/>Module boundaries enforced<br/>Lightweight Clean Architecture inside each module]
Worker --> WMono[Same module structure<br/>Consumers + Quartz scheduled jobs]
WQW solves "sync requests must be fast, background work can be slow" (covered in a separate post); the modular monolith solves "single-process deployment but modules can't bleed into each other" — and that latter half is this post's subject.
Why Boundaries Must Be Enforced at Compile Time, Not by Good Intentions
The most common way a modular monolith dies isn't bad design — it's good design that nobody upholds. A rule like "go through Contracts only, never reference internal implementations," if it lives only in a doc, has a half-life of maybe a few iterations. One day someone in a hurry imports another module's internal class directly; it compiles, tests pass, it merges and ships, and the boundary now has a hole. The next person sees the hole, assumes it's allowed, and opens another. That's how rot accumulates: not one collapse, but a "it's just this one spot" compromise at each call site.
This is why the system pushes the boundary rules down into NetArchTest from day one, enforced at compile time. The 11 modules share one deployment but keep hard boundaries:
- Each module owns its own
DbContextand EF Core migrations; - Cross-module calls can only go through the
.Contractsproject (pure DTOs and events), never referencing another module's internal implementation; - Async boundaries go over messages (MassTransit).
The architecture tests verify exactly those "you'll forget if you have to remember them" rules:
- A module may only reference other modules'
.Contractsprojects; BuildingBlocks(the shared kernel) depends on no infrastructure (no EF Core, no MassTransit);- The Api host doesn't reference the Worker, and the Worker doesn't reference the Api;
Contractsprojects have no internal dependencies (pure DTOs).
The dependency direction is strictly one-way:
flowchart TD
BB[BuildingBlocks<br/>No infrastructure dependencies]
Contracts[Module.Contracts<br/>DTOs, events]
Msg[Infrastructure.Messaging<br/>MassTransit adapter]
Module[Module internal implementation<br/>Endpoints/App/Domain/Infra<br/>May reference other modules' Contracts, never internals]
Host[Api / Worker host<br/>Composition root, references all modules]
BB --> Contracts
BB --> Msg
Contracts --> Module
Module --> Host
Put it more generally: any architectural constraint "maintained by discipline" is essentially a contract that lives only in a doc, and doc-borne constraints rot steadily as the system grows. What stops rot is never a more conspicuous comment — it's a test that turns red in CI. NetArchTest plays the boundary's gatekeeper here: try to bleed across a module and the build just fails. This is the dividing line between a modular monolith that stays healthy long-term and one that doesn't. No exceptions.
Why the Shared Kernel Deliberately Depends on Nothing
BuildingBlocks is referenced by both Api and Worker, but it contains only abstractions, no concrete technology:
| Abstraction | Role |
|---|---|
Result<T> | Unified return type: success+data or failure+error. Use it instead of exceptions for "expected" failures |
ICommandDispatcher | Send a command without the caller knowing whether it's in-process or over RabbitMQ |
IEventPublisher | Publish integration events to all subscribers; modules never import MassTransit directly |
IIntegrationEvent | Marker interface for cross-module/cross-host events |
IScraperApiClient | HTTP client abstraction for calling the external scraping system |
"Deliberately depending on no infrastructure" sounds like fastidiousness, but it buys three concrete benefits you can point at:
- Unit-testable: mock
ICommandDispatcher/IEventPublisherdirectly; tests don't need to spin up RabbitMQ; - Swappable transport: want to replace MassTransit with another middleware? You change only the
Infrastructure.Messagingproject; - Module safety: any module referencing
BuildingBlockswon't accidentally drag in EF Core or RabbitMQ.
The third point meshes with the boundary enforcement above — if the shared kernel itself dragged EF Core along, the rule "modules don't touch infrastructure" would be leaking from the foundation. So this maps to a general principle: infrastructure at the edges, core abstractions kept pure. Purity isn't for looks; it's so the boundary rules hold at the foundation.
CQRS-lite and the Anti-Pattern Called Out by Name
Not full CQRS, no event sourcing — hence "lite":
- Commands change state, going through
ICommandDispatcher(async over RabbitMQ when cross-module); - Queries are sync HTTP, with complex reads optimized via Dapper.
Data access is dual-strategy:
| Concern | Tool |
|---|---|
| CRUD, transactions, aggregate persistence | EF Core |
| Complex lists, reports, statistics, cross-schema reads | Dapper |
Why not pure EF Core? Because there's a heap of reporting, time-series aggregation, and cross-schema reads, and writing window functions and pivots in pure EF Core gets awkward. Why not pure Dapper? Because we need database portability — EF Core's provider abstraction covers writes, migrations, and schema management. There's no "which is better" here, only "which tool is cheaper on which stretch of road."
Worth pulling out on its own is an explicitly rejected anti-pattern: why not a Generic Repository. IRepository<T> manufactures a fake abstraction — it either leaks ORM details (exposing IQueryable<T>, in which case it encapsulates nothing real) or over-constrains queries (sealing off things the ORM could otherwise do). It loses both ways. So each module defines persistence directly through DbContext or a domain-specific query service, with no pointless repository interface layered on top. This is a good heuristic: an abstraction that can neither fully hide what it wraps nor lets you fully use what it wraps is a liability.
Explicit Over Magic: In a Small Team, "Readable" Is Worth More Than "A Few Fewer Lines"
Each module has a ModuleRegistration.cs providing Add{Module}(services, config) and Map{Module}(app). No assembly scanning for auto-registration. The startup flow reads top to bottom: which modules are registered, which routes are mapped — all at a glance. Combined with Minimal API (not MVC Controllers), each module owns its endpoints in its own Endpoints/, with no central Controllers/ junk drawer.
The essence of this tradeoff is a time ledger: assembly scanning saves the few minutes of writing registration code, at the cost of a longer trail when something breaks — "where is this endpoint registered, and why isn't it active?" takes ages to chase in scan-based registration, but is obvious at a glance with explicit registration. For a 1–2 person team, debugging time is far scarcer than keystrokes. Trading "magic" for "readable," those saved lines aren't worth mentioning.
Project Layout and Internal Module Structure
flowchart TD
Root[apps/api/]
Root --> Props[Directory.Build.props / Directory.Packages.props]
Root --> Src[src/]
Root --> Tests[tests/]
Src --> BB2[BuildingBlocks shared kernel, interfaces only]
Src --> Msg2[Infrastructure.Messaging]
Src --> ApiH[Api composition root]
Src --> WorkerH[Worker composition root]
Src --> Modules[Modules/ one pair of projects per module]
Tests --> Arch[Architecture: NetArchTest boundary enforcement]
Tests --> Hosts[Hosts: Testcontainers integration tests]
Tests --> ModTests[Modules: module tests]
Each module is internally a "lightweight Clean Architecture" — complex modules get full layering, simple CRUD isn't forced into it:
Modules.{Name}/
├── ModuleRegistration.cs # Add{Name} + Map{Name}
├── Endpoints/ # Minimal API endpoint groups (HTTP mapping only)
├── Application/
│ ├── Commands/ # Write-side use cases
│ └── Queries/ # Read side (the complex ones; simple reads stay in the endpoint)
├── Domain/ # Entities, value objects, domain events
└── Infrastructure/
├── Persistence/
│ ├── {Name}DbContext.cs # .HasDefaultSchema("{schema}") for isolation
│ └── Migrations/
└── Queries/ # Dapper query services
That note next to Application/Queries — "simple reads stay in the endpoint" — is the whole meaning of "lightweight": don't manufacture a Query handler for a three-field query.
Schema-per-Module, Plus One Pragmatic Exception
A single PostgreSQL instance, modules isolated by schema:
| Module | schema |
|---|---|
| Monitoring | monitoring |
| FlowMeters | flow_meters |
| TankManagement | tanks |
| Identity | auth |
| Geospatial | geospatial (needs PostGIS) |
| ... | ... |
DbContext uses UseNpgsql().UseSnakeCaseNamingConvention(), so PascalCase entities map automatically to snake_case columns. Migrations are stored per module.
The exception is here: cross-module read-only Dapper queries are allowed (read-only read models, producing no write coupling). For instance, the heatmap module can Dapper-query the dust-monitoring module's tables to build a report. The rule in one line — read models OK, write coupling not. This exception isn't a hole in the boundary; it's the boundary's precise definition: the boundary exists to block coupling caused by writes, not reads. Cutting reads off too would only force out a pile of event round-trips that exist purely to shuffle data around.
Test the Boundaries, Not the Internals
- Architecture tests (NetArchTest) — enforce dependency rules at compile time;
- Integration tests (Testcontainers) — spin up real PostgreSQL + RabbitMQ, with
CustomWebApplicationFactorywrapping the full ASP.NET Core pipeline. We insist on real infrastructure rather than in-memory fakes, because the EF in-memory provider behaves differently from real PostgreSQL, and a "green" from an in-memory fake is a false green; - HTTP contract tests — verify endpoints conform to the OpenAPI schema, detecting drift in CI;
- Module tests — domain logic and Dapper queries, kept isolated from infrastructure as much as possible.
How the Option Actually Stays in Your Hand
Back to that "split option" from the opening. An option isn't a slogan — it only counts if it has a real exercise path in the code, otherwise it's self-deception. In this design, the exercise path is this: module boundaries were drawn from the start with "how would we split this later?" in mind — any module can be extracted into a standalone service. At that point its .Contracts project becomes that service's external API contract directly, and messages already go over RabbitMQ.
The key is this sentence: today's in-process call and tomorrow's cross-service call look like the same ICommandDispatcher interface to module code. That is, when you split, the module's business code barely changes — what changes is the implementation behind ICommandDispatcher, from "in-process dispatch" to "cross-network dispatch," and that implementation is sealed inside the single Infrastructure.Messaging project.
That's the whole value of the option: you enjoy the simplicity of a monolith first (one deployment, easy debugging, no distributed transactions), and you keep "split into microservices" — an expensive move — as an option you can defer and exercise cheaply, paying that cost only when there's real scaling pressure, instead of paying it upfront while you're still unsure you'll ever scale. The cost of microservices isn't avoided; it's deferred until the evidence shows up.
Where It Applies: Honestly Marking the Sweet Spot and the Pitfalls
A modular monolith is almost the textbook-optimal answer for this project — paying the operational cost of one deployment to get the engineering benefit of module isolation. But it's no panacea, and there are clear signals you've crossed a line:
- The team is big enough, and subsystems have independent scaling/release rhythms: only then does the operational cost of microservices buy back its return, and a modular monolith's "single deployment" becomes the bottleneck — multiple teams forced to share one release pipeline, blocking each other;
- Pure simple CRUD that definitely won't grow: even module decomposition is overkill; a layered monolith suffices, and 11 modules is over-engineering;
- Strongly-consistent distributed transactions are a core need: a modular monolith decouples via eventual consistency (Outbox + messages); if the business fundamentally needs cross-module strongly-consistent transactions, this async model will fight you — and you'll find yourself fighting back.
The sweet spot for a modular monolith is: the business has clear domain boundaries, the team is small, and there's genuine uncertainty about whether you'll ever split. Note that last condition — if you're certain you'll never split, the option is worthless; go straight to a layered monolith. If you're certain you'll split soon, go straight to microservices. What a modular monolith sells is precisely the pricing of uncertainty: it lets you hold the choice in hand at very low cost while you still don't know the future. This monitoring platform lands right in the center of the sweet spot — broad domain, small team, hazy future.