Skip to main content

Command Palette

Search for a command to run...

Clean Architecture with CQRS + MediatR in ASP.NET Core: The Complete Guide (2026)

Updated
β€’12 min read
Clean Architecture with CQRS + MediatR in ASP.NET Core: The Complete Guide (2026)

Most .NET teams start with good intentions. Controllers are thin. Services are focused. The codebase is clean.

Then six months pass.

Controllers start calling DbContext directly because "it's just one query." Service classes grow to 600 lines because "it's all related." Business rules get duplicated across three places because nobody can find where they originally lived. Adding a feature requires touching eight files and hoping nothing breaks.

This is not a discipline problem. It is an architecture problem.

Clean Architecture β€” combined with CQRS and MediatR β€” solves this at the structural level. The rules are enforced by the compiler, not by convention. The codebase stays navigable as it grows. And every piece of logic has exactly one place to live.

This guide explains the pattern, why it works, and what a production-grade implementation looks like in ASP.NET Core.

🎁 Get the complete, production-ready Source Code β€” A Fully Working Clean Architecture + CQRS + MediatR solution with FluentValidation, RFC 7807 error handling, and 29 passing tests, exclusively for Coding Droplets Patreon members. πŸ‘‰ Get Source Code


Why Most .NET Projects Become Hard to Maintain

The root cause is almost always the same: no enforced separation between concerns.

When a controller can call a repository directly, someone will. When business logic can live in a service, a controller, or a helper class equally, it ends up in all three. When there is no single place for validation, it gets duplicated β€” or skipped.

Clean Architecture solves this by making the wrong thing structurally impossible.


The Four Layers β€” and What Each One Does

Clean Architecture organizes code into four concentric layers. The rule is simple: dependencies only point inward. Inner layers never reference outer layers.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚               API / UI                  β”‚  ← HTTP, Controllers, Middleware
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚            Infrastructure               β”‚  ← EF Core, Repositories, DB
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚             Application                 β”‚  ← Use cases, CQRS, MediatR
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚               Domain                    β”‚  ← Entities, Interfaces, Rules
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         ↑ Dependencies point inward only

Domain β€” The Core

The Domain layer contains your business entities, domain rules, and repository interfaces. It has zero external dependencies β€” no EF Core, no MediatR, no framework references of any kind.

This is the most important layer. Everything else exists to serve it.

Domain entities use factory methods instead of public constructors, enforcing that objects can only be created in a valid state. Private setters ensure that state can only change through domain methods β€” methods that validate business rules before applying any change.

Application β€” Use Cases

The Application layer contains your use cases β€” the things your system actually does. It references only the Domain, nothing else.

This is where CQRS comes in. Every operation in the system is expressed as either a Command (changes state) or a Query (reads state). Each has exactly one handler. When you need to find where something happens, you always know exactly where to look.

Pipeline behaviors β€” MediatR's middleware β€” handle cross-cutting concerns here: logging, validation, transaction management. These are registered once and apply to every command and query automatically. Handlers stay clean and focused on business logic only.

Infrastructure β€” External Concerns

The Infrastructure layer implements the interfaces defined by the Domain. EF Core lives here. The repository implementations live here. The Domain and Application layers have no idea this layer exists β€” they only see the interfaces.

This is what makes the architecture truly swappable. Changing from EF Core to Dapper, from SQL Server to PostgreSQL, or from one ORM to another requires changes only in Infrastructure β€” zero changes to business logic.

API β€” The Transport Layer

The API layer is deliberately thin. Controllers have one job: accept an HTTP request, dispatch it to MediatR, and return the result. No business logic. No validation. No try/catch blocks.

A single global exception handler catches everything and maps it to RFC 7807 Problem Details β€” the standard error format for HTTP APIs. Clients always get a consistent, structured error response regardless of what went wrong.


What CQRS Actually Solves

CQRS (Command Query Responsibility Segregation) is a pattern that forces you to separate write operations from read operations at the code level.

Without CQRS, service classes tend to accumulate. A ProductService starts with GetProduct and CreateProduct. Then comes UpdateProduct, DeleteProduct, GetProductsByCategory, GetActiveProducts, BulkImportProducts. The class becomes a catch-all.

With CQRS, each operation is its own type:

  • CreateProductCommand β€” creates a product. One handler. One file.

  • GetProductQuery β€” retrieves a product. One handler. One file.

  • GetProductsQuery β€” retrieves a paginated list with optional search. One handler. One file.

Adding a new feature means adding a new Command or Query and its handler. Existing code does not change. This is the Open/Closed Principle in practice.


What MediatR Adds

MediatR is the in-process mediator that makes CQRS ergonomic. Instead of controllers depending directly on service classes, they dispatch to MediatR:

Controller β†’ mediator.Send(command) β†’ Pipeline β†’ Handler β†’ Result

The pipeline is the key. It is where cross-cutting concerns live. Two pipeline behaviors handle everything in this implementation:

Logging behavior β€” wraps every request with structured logging and elapsed time. Applies automatically to all handlers, including ones added in the future. Zero configuration per handler.

Validation behavior β€” auto-discovers FluentValidation validators and runs them before any handler executes. Invalid requests are rejected with structured field-level errors before a single line of business logic runs.


The Result: A Codebase That Stays Maintainable

After wiring all of this together, the development experience changes significantly:

Finding logic is trivial. Need to change how a product is created? It is in CreateProductCommand.cs and CreateProductCommandHandler.cs. Always.

Adding features is predictable. A new use case means a new Command or Query, a new Handler, and optionally a new Validator. Nothing else changes.

Testing is straightforward. Domain logic is pure C# β€” no mocking required. Application handlers mock only the repository interface. Integration tests spin up the full pipeline in-process with isolated test databases.

Errors are consistent. The global exception handler means every unhandled exception produces a structured RFC 7807 response. No partial error handling scattered across controllers.

The compiler enforces the rules. Because each layer is a separate project with explicit project references, accidentally importing EF Core into the Domain layer is a compile error β€” not a code review finding.


What the Production Implementation Looks Like

The complete source code available on Patreon is a fully working, production-ready ASP.NET Core 10 Web API built on everything described in this guide. It is built around a Products domain β€” realistic enough to demonstrate every pattern, simple enough to understand immediately.

Here is what is included:

Solution structure (4 projects + tests):

CleanArchCqrs.sln
β”œβ”€β”€ CleanArchCqrs.Domain/          ← Zero dependencies
β”œβ”€β”€ CleanArchCqrs.Application/     ← CQRS + MediatR + FluentValidation
β”œβ”€β”€ CleanArchCqrs.Infrastructure/  ← EF Core + Repositories
β”œβ”€β”€ CleanArchCqrs.API/             ← Controllers + Middleware
└── CleanArchCqrs.Tests/           ← 29 tests: 22 unit + 7 integration

5 complete use cases: Create, Update, soft-delete, get by ID, and paginated list with search β€” each as a proper Command or Query with its own handler and validator.

Two MediatR pipeline behaviors: Structured logging with elapsed time, and automatic FluentValidation with concurrent validator execution.

Global exception handler mapping domain exceptions, validation errors, and not-found cases to RFC 7807 Problem Details β€” with different log severity levels per exception type.

29 passing tests covering domain invariants (pure unit tests), application handlers (Moq), validator rules (FluentValidation TestHelper), and full HTTP integration (WebApplicationFactory with isolated InMemory databases per test).

EF Core configuration with AsNoTracking() on read queries, index definitions, seeded data, and a one-line swap path to SQL Server.

Swagger UI opens automatically on F5 with 3 pre-seeded products ready to interact with.

The code is heavily commented β€” not just what it does, but why each decision was made. Every design choice is explained in context.


Who This Is For

This source code is for .NET developers who:

  • Know ASP.NET Core and want to move beyond tutorial-level architecture

  • Have heard of Clean Architecture and CQRS but have never built a properly wired implementation from scratch

  • Are about to start a new project and want a production-ready starting point

  • Want to understand how these patterns work together before using them at work

If you have spent time reading about these patterns but felt uncertain about how the pieces actually connect β€” this is what fills that gap.


βœ… Get the complete source code β€” download it, run dotnet run, and have a fully working Clean Architecture + CQRS + MediatR API in minutes. Available exclusively for Coding Droplets Patreon members.

πŸ‘‰ Join Coding Droplets on Patreon

Already a member? The source code is in the Patreon post here.


Frequently Asked Questions

Q: Do I need to understand DDD (Domain-Driven Design) to use Clean Architecture?

No. Clean Architecture and DDD are complementary but independent. You can implement Clean Architecture with simple entities and no DDD concepts at all. The source code in this guide uses domain entities and factory methods β€” patterns that align with DDD β€” but you do not need to know DDD terminology to understand or use the code.

Q: Is Clean Architecture overkill for small projects?

For a genuinely small project β€” a personal tool, a simple internal API, a prototype β€” yes, it may be more structure than you need. But "small" projects have a way of growing. The cost of adding Clean Architecture upfront is low. The cost of retrofitting it onto a 50,000-line codebase is very high. If there is any chance the project will grow, the structure pays for itself quickly.

Q: Does CQRS require two separate databases (read and write)?

No. This is one of the most common misconceptions about CQRS. Separating the read database from the write database (Event Sourcing + read models) is one way to apply CQRS at the infrastructure level β€” but it is an advanced and optional extension. The CQRS in this implementation simply means Commands and Queries are separate code paths. Both use the same database. Start simple, scale if and when you need to.

Q: Can I use this with Minimal APIs instead of controllers?

Yes. The controller in the API layer is just a thin dispatcher β€” it sends a command or query to MediatR and returns the result. Minimal API endpoints do exactly the same thing. The Domain, Application, and Infrastructure layers are completely unaffected by this choice. Swapping controllers for Minimal APIs only touches the API project.

Q: Why use MediatR instead of just calling services directly?

You can absolutely call services directly β€” and for simple applications, that is fine. MediatR adds value through pipeline behaviors. If you want automatic logging, validation, and transaction management applied consistently to every use case without writing that code in each handler, pipeline behaviors are the cleanest way to achieve it. It also decouples the controller from knowing which service to call β€” it only knows what it wants to do (the command), not how to do it.

Q: How does FluentValidation work with the pipeline?

When a command is dispatched through MediatR, the ValidationBehavior runs before the handler. It automatically discovers all AbstractValidator<T> implementations registered for that command type and runs them. If any validation rule fails, a ValidationException is thrown β€” which the global exception handler catches and converts into a 400 Bad Request response with field-level error details. The handler never executes. No try/catch needed anywhere.

Q: What is RFC 7807 Problem Details and why does it matter?

RFC 7807 is the IETF standard for HTTP API error responses. Instead of returning arbitrary JSON error objects that differ between endpoints, it defines a consistent structure: type, title, status, detail, and instance. When your API follows this standard, client developers know exactly what to expect from every error response β€” regardless of which endpoint triggered it. ASP.NET Core has built-in support for it via ProblemDetails and ValidationProblemDetails.

Q: Is the source code production-ready or just a demo?

It is designed to be production-ready in structure and patterns. The database uses EF Core InMemory for simplicity (no setup required), but switching to SQL Server is a one-line change in the Infrastructure registration. The architecture, error handling, validation pipeline, and test structure are exactly what you would use in a real production application. The comments throughout the code explain production considerations and extension points.

Q: Can I extend this to add authentication and authorization?

Yes. ASP.NET Core's authentication and authorization middleware integrates at the API layer β€” the [Authorize] attribute on controllers, or authorization policies in the middleware pipeline. The Domain and Application layers are unaffected. You could also add an authorization pipeline behavior in MediatR to handle resource-based authorization at the use case level.

Q: What is the difference between DomainException and EntityNotFoundException?

DomainException represents a business rule violation β€” something the domain explicitly disallows, like deactivating an already-inactive product. EntityNotFoundException is a specialization that represents the domain rule "this entity must exist." Both are domain-level concerns because "a product must be active to deactivate" and "a product must exist to update" are business rules, not infrastructure concerns. Both map to different HTTP status codes in the global exception handler: 422 (Unprocessable Entity) for domain violations, 404 (Not Found) for missing entities.


πŸ’» Explore the Project Structure on GitHub

Before diving into the full implementation, you can explore the complete folder structure and architecture in the free starter template on GitHub.

Clone it, open it in your IDE, and browse through every layer β€” Domain, Application, Infrastructure, and API β€” to understand how the pieces fit together.

πŸ‘‰ dotnet-clean-architecture-cqrs-starter on GitHub

The starter gives you the full structure with stub implementations. The complete, working version with all business logic, pipeline behaviors, and 29 tests is on Patreon.

More from this blog

C

Coding Droplets

128 posts