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 (2)
The extraction step is where I’d add one more enforceable boundary: keep the in-process notification and the integration event as separate contracts. A MediatR handler can share the caller’s process and transaction; a broker introduces serialization, delivery retries, duplicates, reordering, and a commit/publish gap. Before extraction, I’d run a small contract suite against the
*.Contractsassembly: deserialize the previous event version, reject leaked EF entities, deliver a duplicate and an out-of-order event, then assert the consumer’s durable postcondition. The outbox closes the source-side gap, but it does not make the consumer idempotent. I’d also retain one cross-module CI lane alongside the parallel module lanes, because isolated green builds cannot prove contract compatibility. Do you model domain notifications and integration events separately today, or version the same DTO for both?Yes — separate contracts. The MediatR notification carries whatever the in-process handler needs; the integration event is a narrower, versioned DTO in Contracts — no EF entities, no internal-only fields — and it's the only one serialized to the outbox. Sharing one DTO for both is exactly the trap: internal field changes start breaking consumers you can't see.
The contract suite you're describing isn't in the post but should be — deserialize-previous-version, reject-leaked-entities, duplicate/out-of-order delivery, assert consumer idempotency. Good addition to the extraction checklist. Same with the cross-module CI lane — parallel matrix proves each module is green in isolation, not that Orders' event shape still satisfies Payments' consumer. Fair gap to call out.
I will try accommodating it by editing the article on next revision.