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.
When agents use itUse 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.
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill dotnet-architecture -a github-copilot
# or with the org installer (adds .github/skills/dotnet-architecture):
npx -y github:AGCO-Global/org-skills add skill dotnet-architecture
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the backend-dotnet-skills plugin, which bundles all Backend / .NET skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install backend-dotnet-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill dotnet-architecture -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill dotnet-architecture -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill dotnet-architecture -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill dotnet-architecture -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Skill contents
.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.
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 ifs 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:
Context and forces — load, team size, domain complexity, compliance constraints (ask if unknown).
Recommendation — deployment shape, module map, code style, with a solution tree.
ADR(s) in the form: Title / Status / Context / Decision / Consequences (positive and negative) / Alternatives considered.
Migration path from the current state in small, independently shippable steps.
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".
---
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".