A modular monolith without enforcement is not an architecture — it is a monolith with good intentions.
The Problem
Most teams skip the modular monolith and jump straight to microservices. The ones that do attempt a modular monolith rely on convention — "don't cross module boundaries" — which fails the moment deadlines hit.
The difference between a well-structured modular monolith and a mess is whether boundaries are maintained by tooling or by convention.
The Solution Structure
Each module is a pair of .NET projects:
src/Modules/
Orders/
YourApp.Orders/ ← internal: domain, application, infrastructure
YourApp.Orders.Contracts/ ← public: DTOs, interfaces, events
Payments/
YourApp.Payments/
YourApp.Payments.Contracts/
The rule: modules may only reference each other's *.Contracts projects. The compiler enforces this physically — no project reference means no type access.
Four Layers of Enforcement
- Compiler — project references prevent cross-module type access
- NetArchTest — architecture tests fail the build on namespace-level violations
- NDepend CQLinq — catches dependency cycles and coupling the compiler can't see
- Quality Gates — block PRs that introduce new boundary violations
Module-Scoped Data
Each module owns a dedicated DbContext with a schema prefix (orders.*, payments.*). No module queries another module's tables.
Cross-Module Communication
Modules communicate via MediatR in-process events. Orders publishes OrderPlaced; Payments subscribes — without Orders knowing Payments exists.
This is also the extraction seam: when you eventually extract a module into a service, MediatR becomes a message broker. The event contract stays the same.
Parallel CI
strategy:
matrix:
module: [Orders, Payments, Inventory]
fail-fast: false
Each module's tests run in parallel. CI time scales with the slowest module, not the total count.
The Extraction Path
When a module genuinely needs independence:
- Add outbox table → publish to real broker
- Replace MediatR handlers with broker consumers
- Deploy module as separate service
- Publish
*.Contractsas NuGet package
The boundary was already clean. Extraction is a deployment change, not a redesign.
The full post covers NDepend CQLinq rule examples, Quality Gate configuration, GitHub Actions pipeline YAML, test isolation patterns, and a production checklist.
Top comments (0)