Bhubaneswar, Odisha, India
+91-8328865778
support@softchief.com

CQRS and MediatR in ASP.NET Core: A Practical Guide for Modern Enterprise Applications

CQRS and MediatR in ASP.NET Core: A Practical Guide for Modern Enterprise Applications

Introducion

Modern enterprise applications rarely remain simple CRUD systems for long. As an application grows, business rules become more complex, APIs handle different types of workloads, and development teams need a structure that keeps features maintainable and testable.

A common challenge is that read operations and business operations often have very different requirements. A customer dashboard may need optimized queries and lightweight data, while creating an order may require validation, business rules, transactions and integration with other services.

This is where CQRS (Command Query Responsibility Segregation) can be useful.

CQRS separates operations that change application state from operations that retrieve application data. In ASP.NET Core applications, developers can use libraries such as MediatR to implement a mediator-based request-handling approach for commands and queries.

However, CQRS and MediatR are not the same thing.

  • CQRS is an architectural pattern.
  • MediatR is a .NET library that implements the Mediator pattern.
  • CQRS can be implemented without MediatR.
  • MediatR can be used without implementing full CQRS.

When applied appropriately, the combination can provide a clean way to organize application workflows, business operations and cross-cutting concerns.

This guide explains how CQRS and MediatR work together in ASP.NET Core, when to use them, their benefits and limitations, and how to structure a practical implementation.

What Is CQRS?

CQRS stands for Command Query Responsibility Segregation.

The core idea is to separate operations that change application state from operations that read application state.

A command represents an intention to change something.

Examples include:

  • Create an order
  • Update a customer
  • Approve an invoice
  • Cancel a subscription
  • Process a payment

A query retrieves information without changing application state.

Examples include:

  • Get customer details
  • Search products
  • Retrieve order history
  • Get sales information
  • Display a dashboard

A simplified model looks like this:

                 Application

                     API
                      |
          -------------------------
          |                       |
       Commands                 Queries
          |                       |
    Command Handler         Query Handler
          |                       |
      Write Model             Read Model
          |                       |
       Database              Data Store

The important point is that CQRS does not necessarily require separate databases.

A small or medium-sized application can use the same database while keeping command and query code logically separate.

More complex systems may introduce dedicated read models or separate data stores when the application’s requirements justify the additional complexity.


Traditional CRUD vs CQRS

In a traditional CRUD application, a service might contain operations such as:

CustomerService

CreateCustomer()
UpdateCustomer()
DeleteCustomer()
GetCustomer()
SearchCustomers()

This can be perfectly appropriate for straightforward applications.

The challenge appears when the service starts accumulating:

  • Complex business rules
  • Multiple integrations
  • Complicated queries
  • Reporting requirements
  • Authorization rules
  • Transaction management
  • Different performance requirements

The result can become a large service with many unrelated responsibilities.

With CQRS, those responsibilities can be separated:

                 Customer API
                      |
          -------------------------
          |                       |
       Commands                 Queries
          |                       |
CreateCustomerCommand     GetCustomerQuery
UpdateCustomerCommand     SearchCustomersQuery
DeleteCustomerCommand     GetCustomerQuery
          |                       |
      Handlers                 Handlers

This separation makes it easier to reason about individual application operations.


Understanding Commands

A command represents an operation that intends to change application state.

For example:

public record CreateCustomerCommand(
    string Name,
    string Email
);

The command does not contain the implementation of the operation.

It simply describes the requested action.

Conceptually:

CreateCustomerCommand

Name: John Smith
Email: john@example.com

The handler decides how that request should be processed.


Understanding Queries

A query represents an operation that retrieves information without modifying application state.

For example:

public record GetCustomerQuery(int CustomerId);

A query handler can retrieve the required information and return a DTO specifically designed for the API.

GetCustomerQuery
       |
       v
Query Handler
       |
       v
Database
       |
       v
CustomerDto

This allows the read operation to be optimized independently from business operations where necessary.


What Is MediatR?

MediatR is a .NET library based on the Mediator design pattern.

The Mediator pattern allows components to communicate through a central mediator rather than directly depending on one another.

Without a mediator, an API controller might directly depend on several application services.

Controller
    |
    +-- CustomerService
    |
    +-- PaymentService
    |
    +-- NotificationService

With a mediator-based approach:

Controller
    |
    v
  Mediator
    |
    v
Request Handler

The controller sends a request, and the mediator dispatches it to the appropriate handler.

This can reduce direct dependencies in the API layer.


CQRS and MediatR: How Do They Work Together?

CQRS defines how responsibilities are separated.

MediatR provides a mechanism for dispatching requests to handlers.

For example:

HTTP Request
     |
     v
ASP.NET Core Controller
     |
     v
MediatR
     |
     v
CreateOrderHandler
     |
     v
Domain/Application Logic
     |
     v
Database

For a query:

HTTP Request
     |
     v
ASP.NET Core Controller
     |
     v
MediatR
     |
     v
GetOrderHandler
     |
     v
Database
     |
     v
OrderDto

This structure can make individual application operations easier to locate, test and maintain.


A Practical CQRS Project Structure

A typical ASP.NET Core application might organize its application layer like this:

Application
│
├── Commands
│   ├── CreateOrder
│   │   ├── CreateOrderCommand.cs
│   │   └── CreateOrderHandler.cs
│   │
│   └── CancelOrder
│       ├── CancelOrderCommand.cs
│       └── CancelOrderHandler.cs
│
├── Queries
│   ├── GetOrder
│   │   ├── GetOrderQuery.cs
│   │   └── GetOrderHandler.cs
│   │
│   └── SearchOrders
│       ├── SearchOrdersQuery.cs
│       └── SearchOrdersHandler.cs
│
├── DTOs
│
├── Behaviors
│
└── Validators

Infrastructure-related components can remain separately organized:

Infrastructure
│
├── Persistence
├── Repositories
├── ExternalServices
└── Integrations

The exact structure should depend on the application’s size and architectural requirements.

There is no requirement that every ASP.NET Core application must use this exact folder structure.


Implementing CQRS with MediatR

Let’s look at a simplified product example.

Step 1: Define the Command

A command can implement IRequest<T> when it needs to return a result.

public record CreateProductCommand(
    string ProductName,
    decimal Price
) : IRequest<int>;

Here, int represents the response type.

The application expects the handler to return the ID of the newly created product.


Step 2: Create the Command Handler

The handler processes the command.

public class CreateProductHandler
    : IRequestHandler<CreateProductCommand, int>
{
    private readonly ApplicationDbContext _context;

    public CreateProductHandler(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<int> Handle(
        CreateProductCommand request,
        CancellationToken cancellationToken)
    {
        var product = new Product
        {
            Name = request.ProductName,
            Price = request.Price
        };

        _context.Products.Add(product);

        await _context.SaveChangesAsync(cancellationToken);

        return product.Id;
    }
}

The handler is responsible for processing one application operation.

In a real enterprise application, the handler may also interact with domain logic, repositories or other application services depending on the architecture.


Step 3: Create a Query

A query can be defined as:

public record GetProductQuery(
    int Id
) : IRequest<ProductDto>;

The query requests a specific product.


Step 4: Create the Query Handler

public class GetProductHandler
    : IRequestHandler<GetProductQuery, ProductDto>
{
    private readonly ApplicationDbContext _context;

    public GetProductHandler(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<ProductDto> Handle(
        GetProductQuery request,
        CancellationToken cancellationToken)
    {
        var product = await _context.Products
            .AsNoTracking()
            .Where(x => x.Id == request.Id)
            .Select(x => new ProductDto
            {
                Id = x.Id,
                Name = x.Name,
                Price = x.Price
            })
            .SingleOrDefaultAsync(cancellationToken);

        if (product is null)
        {
            throw new KeyNotFoundException(
                $"Product with ID {request.Id} was not found.");
        }

        return product;
    }
}

Notice that the query returns a DTO instead of exposing the database entity directly.

This approach can help keep the API contract separate from the persistence model.


Step 5: Send the Request Through MediatR

A controller can send the command through MediatR:

[HttpPost]
public async Task<IActionResult> Create(
    CreateProductCommand command,
    CancellationToken cancellationToken)
{
    var productId = await _mediator.Send(
        command,
        cancellationToken);

    return Ok(productId);
}

For a query:

[HttpGet("{id:int}")]
public async Task<IActionResult> Get(
    int id,
    CancellationToken cancellationToken)
{
    var result = await _mediator.Send(
        new GetProductQuery(id),
        cancellationToken);

    return Ok(result);
}

The controller remains focused primarily on HTTP concerns while application behavior resides in handlers.


MediatR Pipeline Behaviors

One of the useful features of MediatR is its pipeline behavior mechanism.

Pipeline behaviors can execute logic around request handling.

A simplified flow looks like:

Request
   |
   v
Validation Behavior
   |
   v
Logging Behavior
   |
   v
Performance Behavior
   |
   v
Handler
   |
   v
Response

Common use cases include:

  • Validation
  • Logging
  • Performance measurement
  • Transaction handling
  • Request tracing
  • Application-level authorization checks

This helps avoid repeating the same cross-cutting logic in every handler.


Validation with FluentValidation

For example, a product command could have validation rules:

public class CreateProductValidator
    : AbstractValidator<CreateProductCommand>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.ProductName)
            .NotEmpty()
            .MaximumLength(200);

        RuleFor(x => x.Price)
            .GreaterThan(0);
    }
}

The validation logic remains separate from the command handler.

The handler can therefore focus on the application operation instead of containing a long list of input-validation rules.


CQRS with Entity Framework Core

Entity Framework Core is commonly used for persistence in ASP.NET Core applications.

A CQRS implementation can use EF Core on both the command and query sides.

For example:

Command Handler
      |
      v
Entity Framework Core
      |
      v
Database

And:

Query Handler
      |
      v
Entity Framework Core
      |
      v
Database

This is still CQRS if the application maintains separate command and query responsibilities.

A separate read database is not required.

For read-heavy workloads, developers may choose technologies such as Dapper or optimized SQL queries where appropriate.


When Should You Use a Separate Read Model?

A more advanced CQRS architecture may introduce a dedicated read model.

For example:

                 Application
                      |
            ---------------------
            |                   |
        Command              Query
            |                   |
        Write Model          Read Model
            |                   |
       Write Database      Read Database

This can be useful when read and write workloads have substantially different requirements.

For example, an e-commerce platform might use:

  • A transactional database for orders
  • A denormalized read model for dashboards
  • A reporting database for analytics

However, this architecture introduces additional concerns such as:

  • Data synchronization
  • Event processing
  • Eventual consistency
  • Failure handling
  • Monitoring
  • Operational complexity

Therefore, it should be introduced because the application needs it—not simply because CQRS is being used.


Real-World Example: E-Commerce Application

Consider an e-commerce platform.

The application needs to support:

Commands

Place Order
Cancel Order
Update Shipping Address
Process Payment

Queries

Get Order Details
View Order History
Search Orders
View Customer Dashboard

A possible architecture is:

                 E-Commerce API
                       |
             ---------------------
             |                   |
         Commands              Queries
             |                   |
          Handlers             Handlers
             |                   |
        Write Model          Read Model
             |                   |
       Transaction DB       Read Store

The write side can focus on transactional consistency and business rules.

The read side can be optimized for the data required by customers, support teams and reporting interfaces.


CQRS and Event-Driven Architecture

CQRS can also be combined with event-driven architecture.

For example:

CreateCustomerCommand
          |
          v
Customer Handler
          |
          v
Customer Created
       Event
          |
    ----------------
    |       |      |
    v       v      v
 Email   Billing  Analytics
Service   Service   Service

This allows other parts of a distributed system to react to business events.

However, event-driven CQRS introduces additional architectural considerations, including:

  • Event delivery
  • Retry strategies
  • Idempotency
  • Event versioning
  • Eventual consistency
  • Dead-letter handling
  • Observability

These concerns should be addressed before adopting this architecture in production.


Benefits of CQRS and MediatR

1. Clear Separation of Responsibilities

Commands and queries have different purposes.

This makes application behavior easier to organize.


2. Focused Handlers

Each handler can represent a specific application operation.

For example:

CreateOrderHandler
CancelOrderHandler
GetOrderHandler
SearchOrdersHandler

This can make features easier to locate and test.


3. Improved Testability

Handlers can be tested independently.

For example, a test can focus specifically on:

CreateOrderHandler

without requiring an end-to-end test for every aspect of the API.


4. Flexible Read and Write Optimization

When application requirements justify it, read and write paths can be optimized independently.

The query side might use projections, caching or specialized read models, while the command side can focus on transactional business operations.


5. Cleaner Cross-Cutting Concerns

Pipeline behaviors can centralize concerns such as:

  • Validation
  • Logging
  • Performance monitoring
  • Transactions

This can reduce repetitive code.


Challenges of CQRS and MediatR

CQRS is not a universal solution.

1. Additional Complexity

A simple CRUD application might require only:

Controller
   |
Service
   |
Database

Introducing:

Command
Handler
Query
Handler
DTO
Behavior
Validator

may create unnecessary complexity for a small application.


2. More Code

CQRS typically introduces additional classes and abstractions.

Teams should consider whether the additional structure provides enough value for the application.


3. Eventual Consistency

When separate read models are introduced, the read side may not immediately reflect changes made to the write side.

This is an important consideration for user experience and business processes.


4. Operational Complexity

Distributed CQRS implementations can require additional infrastructure for:

  • Messaging
  • Event processing
  • Monitoring
  • Retry handling
  • Data synchronization

The architecture should therefore evolve according to actual requirements.


CQRS Best Practices for ASP.NET Core

1. Don’t Use CQRS Everywhere

CQRS is particularly useful when an application has meaningful differences between its read and write workloads or complex business operations.

For straightforward CRUD applications, a simpler architecture may be easier to maintain.


2. Keep Handlers Focused

A handler should represent a coherent application operation.

Avoid creating handlers that contain unrelated business workflows.


3. Use DTOs for API Contracts

Avoid exposing persistence entities directly from APIs when doing so creates unwanted coupling.

Instead, use dedicated request and response models where appropriate.


4. Keep Business Rules in the Appropriate Layer

Not every business rule belongs inside a MediatR handler.

For domain-heavy applications, important business invariants may belong in domain entities or domain services.

The handler can coordinate the application workflow while domain logic remains within the domain model.


5. Use Pipeline Behaviors for Cross-Cutting Concerns

Validation, logging and performance monitoring are good candidates for reusable pipeline behaviors.

This keeps individual handlers focused.


6. Optimize Queries Based on Evidence

Don’t introduce Dapper, separate databases, caching or read replicas simply because they are commonly associated with CQRS.

Measure application behavior first and introduce optimization where there is a demonstrated requirement.


7. Consider Cancellation Tokens

ASP.NET Core applications should propagate cancellation tokens through asynchronous operations where appropriate.

For example:

await _context.SaveChangesAsync(cancellationToken);

and:

await _mediator.Send(command, cancellationToken);

This helps the application respond appropriately when a request is cancelled.


CQRS and Clean Architecture

CQRS can work well with Clean Architecture because both approaches encourage separation of responsibilities.

A simplified architecture might look like:

Presentation
     |
     v
Application
     |
     v
Domain
     |
     v
Infrastructure

The application layer can contain:

  • Commands
  • Queries
  • Handlers
  • DTOs
  • Application services
  • Pipeline behaviors

The domain layer can contain:

  • Entities
  • Value objects
  • Domain services
  • Business rules
  • Domain events

Infrastructure can handle:

  • EF Core
  • Database access
  • External APIs
  • Messaging
  • File storage

CQRS does not require Clean Architecture, but the two approaches can complement each other in complex systems.


CQRS and Microservices

CQRS can also be used within microservices.

For example, an Order service might expose:

Commands

Create Order
Cancel Order
Confirm Order

and:

Queries

Get Order
Get Order History
Search Orders

Each service can maintain its own application logic and persistence strategy.

However, microservices already introduce operational complexity, so CQRS should be introduced only where it provides a clear architectural benefit.


When Should You Use CQRS?

CQRS can be considered when an application has one or more of the following characteristics:

  • Complex business workflows
  • Significant differences between read and write workloads
  • Complex domain logic
  • Different read and write performance requirements
  • Reporting or dashboard workloads
  • Multiple consumers of business events
  • A need for independently optimized read models
  • Large teams that benefit from clearly separated application operations

It may be unnecessary when the application is primarily straightforward CRUD with limited business complexity.


CQRS vs Traditional CRUD: A Practical Decision

The choice does not have to be:

CRUD OR CQRS

A more practical approach is to evaluate each part of the application.

For example:

Simple Customer Settings
        |
     CRUD

Complex Order Processing
        |
      CQRS

Reporting
        |
Optimized Queries

An application can use different approaches for different features.

This is often more practical than forcing an entire application into one architectural pattern.


Conclusion

CQRS provides a structured way to separate state-changing operations from read operations in ASP.NET Core applications.

MediatR can complement CQRS by providing a mediator-based request-handling mechanism that connects controllers or other application entry points with command and query handlers.

A typical implementation may look like:

API
 |
MediatR
 |
-------------------------
|                       |
Commands              Queries
|                       |
Handlers              Handlers
|                       |
Write Logic           Read Logic
|                       |
Database              Read Model

The combination can be valuable for applications with complex business workflows, different read and write requirements, or a need for clearly organized application operations.

At the same time, CQRS introduces additional abstraction and complexity. A simple CRUD application may not benefit from it.

The key is to use CQRS and MediatR where they solve an actual architectural problem rather than adopting them simply because they are popular patterns.

For ASP.NET Core developers working on enterprise APIs, domain-driven applications and distributed systems, understanding when to use CQRS, when to keep things simple, and how MediatR fits into the architecture is more important than simply knowing how to create a command or handler.

Leave a Reply