DEV Community

Cover image for Enforcing Modular Monolith Boundaries in .NET: NDepend, Parallel Pipelines, and the Architecture That Holds
Alok Ranjan Daftuar
Alok Ranjan Daftuar

Posted on Originally published at aloknecessary.in

Enforcing Modular Monolith Boundaries in .NET: NDepend, Parallel Pipelines, and the Architecture That Holds

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/
Enter fullscreen mode Exit fullscreen mode

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

  1. Compiler — project references prevent cross-module type access
  2. NetArchTest — architecture tests fail the build on namespace-level violations
  3. NDepend CQLinq — catches dependency cycles and coupling the compiler can't see
  4. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add outbox table → publish to real broker
  2. Replace MediatR handlers with broker consumers
  3. Deploy module as separate service
  4. Publish *.Contracts as 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.

👉 Read the complete implementation guide

Top comments (2)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

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 *.Contracts assembly: 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?

Collapse
 
aloknecessary profile image
Alok Ranjan Daftuar

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.