# Dotnet Modular Monolith

> .NET Modular Monolith Architecture

- Skill: `rasheedfisal/dotnet-modular-monolith` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add rasheedfisal/dotnet-modular-monolith`
- Raw SKILL.md: https://api.skillmd.com/api/skills/rasheedfisal/dotnet-modular-monolith/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: rasheedfisal (https://skillmd.com/u/rasheedfisal)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/rasheedfisal/dotnet-modular-monolith

---


# .NET Modular Monolith Architecture

Build a single-deployable .NET solution made of strongly isolated modules (e.g. `Users`, `Billing`, `Catalog`), each internally structured as vertical slices, communicating across module boundaries only through async, durable messaging (outbox → inbox) or thin public API contracts — never through shared database access or direct project references into another module's internals.

This skill is opinionated. It is the synthesis of patterns from production .NET systems (the kind referenced in `running_tracker`/MFI-style codebases): Guard clauses, Value Objects, Result pattern, typed CQRS markers, **decorator-based cross-cutting behaviors (no MediatR required)**, rich domain models with `Raise`, transactional outbox, idempotent inbox, and module-isolation architecture tests.

> **CQRS dispatch — read this before generating handler/behavior code.** This skill defaults to a **library-free** CQRS implementation: your own `ICommandHandler<>`/`IQueryHandler<,>` interfaces (NOT extending `MediatR.IRequest`), handlers registered by assembly scan (Scrutor `Scan`), endpoints injecting the handler interface directly, and cross-cutting concerns (validation, logging, caching) implemented as **decorators** wrapping the handler and chained via Scrutor `Decorate`. This is the approach demonstrated in the host repository (`src/Application/...`). MediatR (`IRequest`/`IPipelineBehavior`/`ISender`/`IPublisher`) remains a fully supported alternative — every code reference shows the decorator form first and the MediatR form as a clearly marked alternative. Pick one per solution and stay consistent; do not mix a MediatR pipeline with hand-rolled decorators for the same handlers.

## When to go deep vs. stay light

Not every project needs all twelve patterns from the foundational list. Triage with the user first:

- **Single module / simple CRUD service** → Tier 1 + Tier 2 patterns only (Guard, Value Objects, Result, CQRS markers, behaviors). Skip outbox/inbox/saga entirely — there's no cross-module boundary to protect.
- **Multiple modules, but synchronous/in-process calls are acceptable (no eventual consistency requirement)** → Add Tier 4 (module API contracts `*.Api` projects + architecture tests) but skip the outbox/saga machinery.
- **Multiple modules with eventual consistency / cross-module workflows that must survive crashes** → Full stack: domain events (`Raise`), transactional outbox, integration events, idempotent inbox, and a saga/process manager for anything spanning more than one event hop.
- **Multi-tenant** → Layer on top of whichever tier above applies. See `references/multi-tenancy.md`. Don't design tenancy in from scratch; retrofit it onto the isolation boundaries that already exist, because tenancy itself is just another cross-cutting concern enforced at the same seams (EF Core query filters, pipeline behaviors, outbox/inbox payloads).

Ask which tier applies before generating a large scaffold — it changes what gets built by an order of magnitude.

## Reality check: what the host repo demonstrates vs. what this skill scales to

The repository this skill ships in is a **single-module clean architecture** (`src/Domain`, `src/Application`, `src/Infrastructure`, `src/Web.Api`, `src/SharedKernel`) — not yet a multi-module solution. It is the canonical, working demonstration of the **Tier 1–2** patterns (Result, Value Objects, EF Core config-per-entity, typed CQRS markers, **decorator behaviors**, Result→HTTP mapping) and a simplified **after-`SaveChanges` in-process** domain-event dispatch. The `Modules/<Module>.{Domain,Application,Infrastructure,Api,Presentation}` layout and the Tier 3/4 outbox/inbox/saga + `*.Api` isolation machinery described below are the **scale-up target**, not code that already exists in this repo. When adapting: copy the Tier 1–2 shapes verbatim from `src/...`, and treat the modular/outbox sections as the design to grow into — don't claim files exist that don't.

## House style (enforced by `.cursorrules` in this repo)

Generated code must match the repo conventions: C# 12 / primary constructors for DI; **explicit typing** (only use `var` when the type is evident from the right-hand side); `internal sealed` by default unless a wider surface is required; `is null` / `is not null` (never `== null`); `Guid` identifiers; records for immutable data; async/await for I/O. The decorator and handler examples below follow these — keep them that way when extending.

## Core principle: module isolation is a project-reference rule, not a folder convention

The single most common failure mode is "modules" that are just folders inside one project, with classes reaching across into each other's repositories or DbContexts. Real isolation means:

1. Each module is its own set of projects: `Module.Domain`, `Module.Application`, `Module.Infrastructure`, `Module.Api` (thin contracts), and a slice of the host (`Module.Presentation` or endpoint registration).
2. **Other modules may only reference `Module.Api`.** Never `Module.Application`, never `Module.Infrastructure`, never `Module.Domain`.
3. Each module owns its own database schema (same physical DB is fine for a monolith; separate schemas with separate `DbContext`s per module — never a shared `DbContext`, never cross-module joins/queries).
4. Cross-module communication is either:
   - A call through the other module's `*.Api` interface (synchronous, in-process, fine for queries that need an immediate answer and where eventual consistency is not required), or
   - An integration event delivered via outbox → message bus/in-process dispatcher → inbox (asynchronous, durable, required whenever a workflow must survive a crash mid-flight or fan out to multiple consumers).
5. Enforce both #2 and #3 with `NetArchTest` architecture tests that fail the build if violated. See `references/architecture-tests.md`.

## The pattern stack (build in this order)

Each pattern below is described briefly; full code for all of them together, wired into one running example module, is in `references/full-example.md`. Read that file before generating any code — it shows how the pieces actually connect, which is the part people get wrong when implementing each pattern in isolation.

### Tier 1 — Foundational, in every module regardless of scale

1. **Guard / Ensure pattern** — centralized invariant checks at construction/entry, using `[CallerArgumentExpression]` for self-documenting exceptions. One static `Ensure` class in the shared kernel; everything else calls into it instead of scattering `if (...) throw`.
2. **Value Objects** — `private` constructor + static `Create(...)` factory returning `Result<T>`, never a public constructor or public setter. Validation lives in the type. This is non-negotiable for money, identifiers, and anything with a domain-meaningful format (emails, codes, rates).
3. **`IDateTimeProvider`** abstraction — banish `DateTime.UtcNow` from domain/handlers so time is injectable and tests are deterministic.

### Tier 2 — Application cross-cutting, per module

4. **Typed CQRS markers + handler interfaces (library-free by default)** — your own `ICommand`, `ICommand<TResponse>`, `IQuery<TResponse>` marker interfaces and `ICommandHandler<>`, `ICommandHandler<,>`, `IQueryHandler<,>` handler interfaces whose `Handle` method returns `Result`/`Result<T>`. The default approach does **not** extend `MediatR.IRequest`/`IRequestHandler`: handlers are auto-registered with a Scrutor `Scan`, and endpoints inject the handler interface directly (`IQueryHandler<GetUserByIdQuery, UserResponse> handler` → `handler.Handle(...)`) rather than going through `ISender`. This makes "every handler returns Result" a compile-time fact, resolves handlers at compile time (no reflection dispatch), and makes decorators target precisely. *Alternative:* the same markers can extend `MediatR.IRequest<Result<T>>` if you want MediatR's mediator + `IPipelineBehavior` — see `references/full-example.md` §5/§7 for both forms side by side.
5. **Cross-cutting concerns live in decorators (default) or pipeline behaviors (MediatR alternative)** — validation, logging, exception handling, and caching (item 5a) are layered *around* handlers, never copy-pasted into them. Handlers stay pure: load aggregate, call domain method, save, done.
   - **Default (no library):** each concern is a decorator class implementing the same handler interface, taking the inner handler as a constructor dependency, registered with `services.Decorate(typeof(ICommandHandler<,>), typeof(...))`. Registration order = nesting order (last registered runs outermost). The host repo wires `Validation` then `Logging`, yielding `Logging → Validation → Handler` at runtime. Note the natural read/write asymmetry: validation typically decorates **commands only**; queries get logging (and caching) but not validation.
   - **Alternative (MediatR):** the same concerns as `IPipelineBehavior<,>` implementations registered via `AddOpenBehavior`.
5a. **Caching as a query decorator** — wrap `IQueryHandler<,>` with a `CachingDecorator` that depends on `IDistributedCache` (provider-agnostic; Redis via `AddStackExchangeRedisCache` is the recommended production backend, `MemoryDistributedCache` for dev/test). Key on query type name + serialized query; only cache successful results; let cacheable queries opt in via an `ICachedQuery` marker (with `CacheKey`/`Expiration`) so you don't blanket-cache everything. See `references/full-example.md` §7.
6. **Option pattern** for configuration — strongly-typed, validated-on-startup options classes (`IOptions<T>` / `IOptionsSnapshot<T>` / `IOptionsMonitor<T>`) instead of `IConfiguration["Some:Key"]` scattered through the codebase. Validate with `IValidateOptions<T>` or data annotations + `ValidateOnStart()`.
7. **EF Core configuration pattern** — every entity gets its own `IEntityTypeConfiguration<T>` class (never fluent config inline in `OnModelCreating`, never data-annotation attributes on domain entities), applied via `ApplyConfigurationsFromAssembly`. Keeps the domain model persistence-ignorant.
8. **Result → HTTP mapping** — one `result.Match(Results.Ok, CustomResults.Problem)` at the endpoint, not per-endpoint status-code logic.

### Tier 3 — Required once you have more than one module with eventual consistency

9. **Rich domain model + `Raise` pattern** — aggregates expose behavior methods (`LoanApplication.Approve()`), not public setters; those methods call `Raise(IDomainEvent)` to record what happened. This is the trigger source for everything below — without it there's nothing for the outbox to pick up. (`Raise` is `protected` if only the aggregate raises its own events — the stricter, preferred default; the host repo currently exposes it as `public` on the `Entity` base. Either compiles; prefer `protected` for new code and flag the `public` form in review.) Domain events are dispatched **library-free** by default — a small reflection-wrapper `DomainEventsDispatcher` resolving `IDomainEventHandler<>` implementations (see `src/Infrastructure/DomainEvents/DomainEventsDispatcher.cs`), not MediatR's `IPublisher`/`INotificationHandler`.
10. **Outbox via `SaveChanges` interceptor** — domain events raised during a unit of work are serialized into an `OutboxMessage` row in the _same transaction_ as the data change, closing the dual-write gap. A background processor later reads unprocessed outbox rows and publishes them as integration events.
11. **Inbox / idempotency table** — the flip side of outbox. At-least-once delivery means consumers can receive the same integration event twice; every consumer checks (and records) the event ID in an inbox table before processing, inside the same transaction as its side effects.
12. **Saga / process manager** for anything spanning more than one event hop — e.g. "loan approved → reserve funds → notify client → close application" across three modules. A saga is a small stateful entity (its own table) that reacts to one integration event, performs its step, and either raises the next event or a compensating one on failure. Don't build a generic saga framework up front; start with an explicit state machine per workflow and only abstract once you have two or three real ones to compare.
13. **Public module API contracts (`*.Api` projects)** — thin assemblies containing only interfaces + DTOs (e.g. `IUsersApi`, `UserSummaryDto`). This is what Tier 4's architecture test enforces other modules to depend on.

Full wiring of 9–13 together — domain event → outbox interceptor → processor → integration event → inbox consumer → saga step — is in `references/outbox-inbox-saga.md`, including the idempotency table schema and the failure/retry semantics.

## Workflow for this skill

1. **Clarify scope** — ask which tier (above) actually applies, and how many modules exist or are planned. Don't scaffold sagas for a two-module CRUD app.
2. **Read the relevant reference file(s) before writing code**:
   - `references/full-example.md` — complete worked example: one module (`Orders`) fully built out with all Tier 1–2 patterns, including project layout, every file, and how they reference each other.
   - `references/outbox-inbox-saga.md` — Tier 3 wiring: outbox interceptor, processor, inbox, and a worked saga example (`Orders` → `Inventory` → `Shipping`).
   - `references/architecture-tests.md` — NetArchTest rules for module isolation, no-public-setters, no-DateTime.UtcNow, etc., ready to paste into a `*.ArchitectureTests` project.
   - `references/multi-tenancy.md` — how to layer tenancy onto this architecture: tenant resolution, EF Core global query filters per module, outbox/inbox payload changes, and the "shared schema vs. schema-per-tenant vs. database-per-tenant" tradeoff.
3. **Generate code matching the user's actual module names and domain**, not generic placeholders — adapt the `Orders` example's shape (folder layout, naming, file boundaries) to their domain, don't just rename `Orders` to their term and call it done; check whether their aggregate's actual invariants change which Value Objects or guard clauses are needed.
4. **Always include the architecture test** for whatever isolation boundary was just introduced. A module-isolation pattern without a test enforcing it tends to erode within a few sprints — say this to the user if they want to skip the test.
5. **For multi-tenancy specifically**, ask early which isolation model they want (shared schema + filter, schema-per-tenant, or database-per-tenant) before generating code — it changes the EF Core configuration, the outbox/inbox table shape, and the migration strategy. See the decision table in `references/multi-tenancy.md`.

## Common mistakes to flag proactively

- **Mixing MediatR and hand-rolled decorators for the same handlers** — pick one dispatch model per solution. If handlers don't extend `IRequest`, you cannot use `IPipelineBehavior`; if they do, don't also register Scrutor `Decorate` wrappers on the same interface. The default in this repo is decorators with no MediatR — match it unless the user explicitly wants the library.
- **Validating in the handler** — a handler that runs FluentValidation (or `if (string.IsNullOrEmpty(...))` business-input checks) inside `Handle` instead of in a validation decorator/behavior has leaked a cross-cutting concern into business logic. Keep handlers to: load aggregate → call domain method → save.
- **Caching the wrong thing** — caching commands, or caching failed/`Result.Failure` query results, or blanket-caching every query. Cache only successful query results, and only for queries that opt in (`ICachedQuery`).
- **Reflection-heavy validation result construction** — don't reconstruct a failed `Result<T>` via `MakeGenericMethod`; use a typed `ValidationError` aggregate (`Error[]`) and `Result.Failure<T>(validationError)` as the host repo does (`src/Application/Abstractions/Behaviors/ValidationDecorator.cs`).
- **Shared `DbContext` across modules** — defeats the entire point of isolation; even in one physical database, each module needs its own `DbContext` scoped to its own schema/tables.
- **Anemic domain model with rich-looking names** — a `LoanApplication.Approve()` method that just sets `Status = Approved` without raising a domain event isn't the `Raise` pattern, it's a setter with extra steps. The event is the point.
- **In-memory event bus for cross-module integration events** — fine for same-process domain events within one module's unit of work, but integration events between modules must go through the outbox or they're lost on crash/restart (this was explicitly flagged as a bug to fix, not copy, from the `running_tracker` reference).
- **Sagas without compensating actions** — a saga that only handles the happy path isn't a saga, it's a longer pipeline behavior. Every step that has a side effect needs a defined compensation for when a later step fails.
- **Value Objects with public constructors "for EF Core"** — use private constructors + backing fields and configure EF Core to use them (see `references/full-example.md` for the EF Core configuration that makes this work without exposing a public ctor).
- **Multi-tenancy bolted on via a `WHERE TenantId = @id` the developer has to remember to add** — must be a global query filter enforced at the `DbContext` level, plus an architecture test or analyzer that fails the build if a query bypasses it (e.g. via `IgnoreQueryFilters()` without an explicit justification).

