---
name: dotnet-architecture
description: Architect-level guidance for structuring .NET back-end systems — modular monolith first, vertical slices vs clean/onion layering, project and assembly boundaries, pragmatic domain modelling, CQRS and MediatR trade-offs, messaging with the outbox pattern and idempotency, data ownership, API versioning, cross-cutting concerns, per-environment configuration, OpenTelemetry, local orchestration and container deployment, captured as ADRs. Use this whenever the user is starting a .NET solution, laying out a *.sln or *.slnx with multiple *.csproj projects, choosing between layers and feature folders, debating microservices, adding a message broker, designing aggregates, a shared DbContext or a Directory.Build.props, or asks "how should I structure this .NET service". Also apply it when reviewing a .NET solution or pull request for architectural smells, even if the user only asks about one class.
metadata:
  technology: .NET
  type: architecture
---

# .NET Architecture

Think like the engineer who will still own this system in three years: optimise for changeability and clear boundaries, not for the number of patterns applied. Explain each trade-off to the user rather than dictating, and target the current LTS release of .NET unless the project pins otherwise.

## 1. Decide the deployment shape first

| Question | Default | Change it when |
|---|---|---|
| One deployable or many? | **Modular monolith**: one host, modules with enforced boundaries | Independent scaling/release cadence is *proven* necessary, teams are blocked on each other, or regulatory isolation is required |
| Microservices | Only with mature CI/CD, observability, on-call and a platform team | Never as the starting point for a new product with one team |
| Sync vs async between modules | In-process calls through a module's public contract | Work is slow, cross-module side effects must not block the request, or modules will be extracted later → messages |
| Database | One server, **schema (or at least table ownership) per module** | A module is extracted — its schema moves with it |

A well-bounded modular monolith is the cheapest path to microservices if you ever need them; a distributed monolith is the most expensive path to anything.

## 2. Code organisation: vertical slices vs layers

| Style | Fits | Costs |
|---|---|---|
| **Vertical slices** (feature folders: `Orders/PlaceOrder/{Endpoint, Handler, Validator, Tests}`) | CRUD-heavy or workflow APIs, most line-of-business services | Shared logic must be extracted deliberately; needs discipline to avoid copy-paste |
| **Clean / onion** (`Domain` ← `Application` ← `Infrastructure`, `Api`) | Rich domain rules, long-lived core, multiple delivery mechanisms | More projects and mapping; ceremony if the domain is thin |
| Hybrid (slices inside each module, shared `Domain` per module) | Modular monolith with some complex modules | Needs clear written conventions |

Default: modules as top-level boundaries, vertical slices inside, a real domain layer only in modules whose rules justify it.

## 3. Project and assembly boundaries

```
src/
  Host/                     ← composition root: Program.cs, config, auth, OTel
  Modules/Orders/
    Orders.Contracts/       ← public DTOs, integration events, module interface
    Orders/                 ← internal implementation (slices, domain, EF DbContext)
  Modules/Billing/...
  BuildingBlocks/           ← small, stable: result types, outbox, messaging abstractions
tests/
Directory.Build.props       ← nullable, warnings-as-errors, analyzers, LangVersion
Directory.Packages.props    ← central package management
```

- Modules reference each other's **Contracts** only. Make implementation types `internal`; enforce with architecture tests.
- A project boundary is a compile-time boundary — use it where it enforces something, not for every folder. Ten projects for a 20-endpoint service is ceremony.
- `BuildingBlocks` must not become a dumping ground; if it depends on a module, it's in the wrong place.

## 4. Domain modelling without ceremony

- **Aggregates** own invariants and are the unit of consistency: one transaction changes one aggregate; cross-aggregate rules are eventually consistent via domain/integration events.
- **Value objects** as `record`/`readonly record struct` (`Money`, `EmailAddress`) with validation in the factory — kills primitive obsession.
- Private setters and behaviour methods (`order.Cancel(reason)`) rather than public setters driven by services; EF Core maps private fields/backing fields fine.
- Reference other aggregates by **id**, not navigation properties across module boundaries.
- Skip DDD tactical patterns in CRUD modules — an anaemic model is fine when there are no invariants.

## 5. CQRS and MediatR

| Need | Recommendation |
|---|---|
| Separate read and write models | Yes: writes go through aggregates; reads are `AsNoTracking` projections (or Dapper/SQL views) straight to DTOs |
| In-process mediator | Optional. Helps with uniform pipeline behaviours (validation, logging, transactions) across many handlers |
| Avoid when | Handlers are one-liners, the team finds navigation harder, or endpoint filters/decorators already cover the cross-cutting needs |

A plain handler class injected into the endpoint is often clearer than `ISender.Send`. Check the current licence terms of MediatR, MassTransit, AutoMapper and similar libraries before adopting — several moved to commercial licensing for newer major versions.

## 6. Messaging and consistency

- Broker abstraction: **MassTransit** or **NServiceBus** (sagas, retries, outbox built in) over raw broker SDKs (Azure Service Bus, RabbitMQ, Kafka) unless you need low-level control.
- **Transactional outbox** whenever a state change and a published message must both happen: write the message to an outbox table in the same DB transaction, relay it afterwards. Never "save, then publish" and hope.
- **Idempotent consumers**: delivery is at-least-once. Deduplicate by message id (inbox table) or make the operation naturally idempotent.
- Distinguish **domain events** (in-process, same transaction) from **integration events** (versioned contracts in `*.Contracts`, published via outbox).
- Retries with backoff, then dead-letter with alerting; poison messages must not block the queue.

## 7. Data ownership

- Each module owns its tables and its `DbContext` (use `HasDefaultSchema("orders")`); no cross-module joins or foreign keys. Need another module's data → call its contract, or keep a local read-model fed by events.
- Reporting that must join everything goes to a separate read store/warehouse, not into the modules.

## 8. API design and versioning

- Version from day one with `Asp.Versioning.Http` (minimal APIs) or `Asp.Versioning.Mvc` — URL segment (`/v1/`) is the most operable default.
- Additive changes don't need a new version; removing/renaming fields does. Diff the OpenAPI document in CI to catch breaking changes.
- Idempotency keys on POSTs that create money-moving or external side effects.

## 9. Cross-cutting concerns

| Concern | Where it lives |
|---|---|
| AuthN/AuthZ | Host: authentication + named policies; modules declare required policies on their endpoint groups |
| Validation, logging, transactions | Endpoint filters, middleware, or mediator pipeline behaviours — one mechanism per solution |
| Errors | ProblemDetails + `IExceptionHandler` in the host |
| Rate limiting, CORS, output caching | Built-in ASP.NET Core middleware configured in the host |
| Time, ids, current user | Injected abstractions (`TimeProvider`, `ICurrentUser`) for testability |

## 10. Configuration per environment

- Same artefact in every environment; differences come from environment variables, `appsettings.{Environment}.json` (non-secret) and a secret store provider. Validate options at startup (`ValidateOnStart`).
- Feature flags (e.g. `Microsoft.FeatureManagement`) instead of environment-name `if`s in business code.

## 11. Observability

- **OpenTelemetry** for traces, metrics and logs (`AddOpenTelemetry().WithTracing(...).WithMetrics(...)`), exporting via OTLP; ASP.NET Core, HttpClient and EF Core instrumentation plus custom `ActivitySource`/`Meter` for business operations.
- Propagate trace context across messages (MassTransit/NServiceBus do this). Every log line correlates to a trace id.

## 12. Local orchestration and deployment

- **Aspire** (formerly .NET Aspire) can orchestrate the host, databases and brokers locally with OTel dashboards and service defaults. Treat it as a developer-experience tool; evaluate its deployment tooling separately against your platform.
- Containers: `dotnet publish` container support or a multi-stage Dockerfile, non-root user, chiseled/distroless base images where possible.
- Health checks: `/health/live` (process up, no dependencies) vs `/health/ready` (dependencies reachable) via `AddHealthChecks()`; wire to orchestrator probes.
- Graceful shutdown: honour `SIGTERM`, drain in-flight requests and consumers within the host shutdown timeout.

## Deliverable format

For architecture requests, produce:
1. **Context and forces** — load, team size, domain complexity, compliance constraints (ask if unknown).
2. **Recommendation** — deployment shape, module map, code style, with a solution tree.
3. **ADR(s)** in the form: *Title / Status / Context / Decision / Consequences (positive and negative) / Alternatives considered*.
4. **Migration path** from the current state in small, independently shippable steps.
5. **Guardrails** — architecture tests, analyzers and CI checks that keep the decision enforced.

## Anti-patterns to reject

- Microservices for a single team's greenfield product; shared database across "services".
- A `Common`/`Shared`/`Utils` project every module depends on and everyone edits.
- Generic repository over EF Core that hides `IQueryable` power and adds nothing (`IRepository<T>` with `GetAll()`).
- Publishing messages outside the DB transaction without an outbox; non-idempotent consumers.
- Cross-module joins, foreign keys or navigation properties; one giant `DbContext` for everything.
- MediatR (or any pattern) adopted by default, then used to call handlers from handlers.
- Environment-specific builds, secrets in config files, business logic branching on environment names.
- Mapping layers that copy identical shapes three times "for purity".
