ASP.NET Core  

Building a CQRS Architecture with MediatR in ASP.NET Core 11

As applications grow, a single model for handling both reads and writes can become difficult to maintain. Business logic becomes tightly coupled, controllers become bloated, and performance optimizations for read-heavy workloads become challenging.

Command Query Responsibility Segregation (CQRS) addresses these issues by separating operations that modify data (Commands) from operations that retrieve data (Queries). Combined with MediatR, CQRS helps organize application logic into small, focused handlers that are easier to test, maintain, and extend.

In this article, you'll build a production-ready CQRS architecture using MediatR in ASP.NET Core 11, understand when to use it, and learn best practices for implementing it effectively.

Note: This article focuses on architectural implementation and testing methodology. Performance improvements depend on application complexity, workload, and infrastructure.

What Is CQRS?

CQRS separates write operations from read operations.

Client
   │
   ▼
 Controller
   │
   ▼
   MediatR
 ┌────┴─────┐
 ▼          ▼
Command   Query
Handler    Handler
 │          │
 ▼          ▼
Database  Database

Commands change application state.

Queries retrieve data without modifying it.

Benefits of CQRS

Using CQRS provides several advantages:

  • Separation of responsibilities

  • Smaller, focused classes

  • Easier unit testing

  • Improved maintainability

  • Independent optimization for reads and writes

  • Cleaner business logic

CQRS is particularly useful for medium to large applications with complex business workflows.

Create the Project

dotnet new webapi -n CqrsDemo

Install MediatR.

dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

Register MediatR

Configure MediatR during application startup.

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(
        typeof(Program).Assembly);
});

MediatR automatically discovers command and query handlers within the specified assembly.

Create the Product Entity

public class Product
{
    public int Id { get; set; }

    public string Name { get; set; } = "";

    public decimal Price { get; set; }
}

Create a Command

Commands represent operations that modify data.

using MediatR;

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

The command returns the ID of the created product.

Create the Command Handler

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

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

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

        _context.Products.Add(product);

        await _context.SaveChangesAsync(
            cancellationToken);

        return product.Id;
    }
}

Each handler focuses on a single business operation.

Create a Query

Queries retrieve data without changing application state.

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

Create the Query Handler

public class GetProductHandler
    : IRequestHandler<GetProductQuery, Product?>
{
    private readonly AppDbContext _context;

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

    public async Task<Product?> Handle(
        GetProductQuery request,
        CancellationToken cancellationToken)
    {
        return await _context.Products
            .AsNoTracking()
            .FirstOrDefaultAsync(
                p => p.Id == request.Id,
                cancellationToken);
    }
}

Using AsNoTracking() improves performance for read-only operations.

Use MediatR in a Controller

Inject IMediator.

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly IMediator _mediator;

    public ProductsController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public async Task<int> Create(
        CreateProductCommand command)
    {
        return await _mediator.Send(command);
    }

    [HttpGet("{id}")]
    public async Task<Product?> Get(int id)
    {
        return await _mediator.Send(
            new GetProductQuery(id));
    }
}

The controller delegates business logic to MediatR, keeping it thin and focused.

Add Validation with Pipeline Behaviors

Cross-cutting concerns can be handled using pipeline behaviors.

public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        Console.WriteLine(typeof(TRequest).Name);

        return await next();
    }
}

Pipeline behaviors can be used for:

  • Logging

  • Validation

  • Performance monitoring

  • Authorization

  • Auditing

This keeps handlers focused on business logic.

End-to-End Request Flow

A typical request follows these steps:

  1. Client sends a request.

  2. Controller receives the request.

  3. Controller sends a command or query to MediatR.

  4. MediatR locates the appropriate handler.

  5. Handler executes business logic.

  6. Database operation completes.

  7. Response is returned to the client.

This separation simplifies testing and maintenance.

Traditional Architecture vs CQRS

FeatureTraditional CRUDCQRS
Read/Write SeparationNoYes
Handler OrganizationShared servicesSeparate handlers
TestabilityModerateExcellent
ScalabilityGoodExcellent
Read OptimizationLimitedIndependent
Complex Business LogicHarder to manageEasier to organize

CQRS introduces additional structure, making it more suitable for applications with growing complexity.

Implementation Methodology

The research brief focuses on production architecture rather than benchmark data. When evaluating a CQRS implementation:

Test Environment

Keep these variables consistent:

  • .NET SDK version

  • Database

  • Dataset size

  • Build configuration

  • Hardware

Test Scenarios

Evaluate:

  • CRUD service architecture

  • CQRS with MediatR

  • Read-heavy workloads

  • Write-heavy workloads

  • Mixed workloads

  • Concurrent requests

Metrics to Measure

Collect:

  • Request latency

  • Throughput

  • Memory allocations

  • Database queries

  • Handler execution time

  • CPU utilization

Useful Tools

Useful tools include:

  • BenchmarkDotNet

  • dotnet-counters

  • dotnet-trace

  • SQL Server Query Store

  • Application Insights

  • MiniProfiler

Measure maintainability and code organization alongside runtime performance.

Best Practices

  • Keep each handler focused on a single responsibility.

  • Use commands only for state changes.

  • Use queries only for data retrieval.

  • Keep controllers thin.

  • Use pipeline behaviors for cross-cutting concerns.

  • Apply AsNoTracking() to read-only queries.

  • Organize handlers by feature rather than type.

  • Unit test handlers independently.

Common Mistakes

MistakeImpact
Putting business logic back into controllersDefeats CQRS benefits
Using one handler for multiple operationsReduced maintainability
Mixing commands and queriesBlurred responsibilities
Ignoring validationInvalid data reaches handlers
Creating CQRS for very small applicationsUnnecessary complexity
Treating MediatR as a service locatorPoor architecture

Troubleshooting

Handler Is Not Invoked

Verify:

  • Handler registration

  • Assembly scanning

  • Request type

  • Dependency injection configuration

Multiple Handlers Found

Ensure only one handler exists for each command or query type.

Poor Performance

Review:

  • Database queries

  • Handler logic

  • Pipeline behaviors

  • Logging overhead

In most cases, performance issues stem from inefficient data access rather than MediatR itself.

FAQs

What is CQRS?

CQRS is an architectural pattern that separates commands (write operations) from queries (read operations) to improve maintainability and scalability.

Is MediatR required for CQRS?

No. CQRS can be implemented without MediatR, but MediatR simplifies request routing and reduces coupling between controllers and business logic.

Should every project use CQRS?

No. Small CRUD applications often do not benefit from the additional abstraction. CQRS is most valuable when business logic becomes complex or read and write workloads differ significantly.

Can CQRS use the same database?

Yes. Many applications begin with a single database for both commands and queries before evolving to separate read models if needed.

Does CQRS improve performance?

Not automatically. Its primary benefit is architectural clarity. Performance improvements come from independently optimizing read and write paths where appropriate.

Conclusion

CQRS, combined with MediatR, provides a clean and maintainable approach to organizing business logic in ASP.NET Core applications. By separating commands from queries, keeping controllers lightweight, and encapsulating each operation in dedicated handlers, developers can build applications that are easier to test, extend, and maintain.

While CQRS introduces additional structure, it becomes increasingly valuable as applications grow in complexity. Applied thoughtfully and supported by proper validation, logging, and monitoring, it forms a solid foundation for scalable, production-ready .NET applications.