Implementing CQRS and Mediator Pattern in ASP.NET Core: Part 1

Building Scalable and Maintainable ASP.NET Core Applications with Command-Query Responsibility Segregation (CQRS) and the Mediator Pattern

F. I.
.NET Insights: C# and ASP.NET Core
7 min readSep 8, 2024

--

In modern ASP.NET Core development, especially when building scalable and complex systems, architectural patterns like CQRS (Command-Query Responsibility Segregation) and the Mediator pattern can significantly improve your application’s architecture. But why should you care about these patterns in the first place?

In this post, we’ll explore not only how to implement CQRS and Mediator in ASP.NET Core, but also the core benefits they offer. By the end, you’ll understand why these patterns matter, when they are the right solution, and how they can enhance the maintainability, performance, and scalability of your applications.

Not a Medium member? You can still read the full blog here.

1. The Problem with Traditional Architectures

In a traditional CRUD architecture (Create, Read, Update, Delete), you often see the same layer handling both read and write operations. For small applications, this simplicity can work well. However, as your application grows, several challenges arise:

  • Complexity increases: Business logic becomes more intricate, and handling both reads and writes in the same layer causes your services and controllers to grow in size.
  • Performance bottlenecks: Read and write operations may have different performance requirements. Optimizing them both with the same model can be difficult.
  • Difficulty in scaling: As your application scales, you may need to scale read and write operations independently. In a traditional CRUD setup, this is difficult without major refactoring.
  • Testability and maintainability suffer: Testing read and write logic intertwined in the same services makes it harder to maintain and ensure the quality of the codebase.

Example: Traditional CRUD Architecture

Here’s a simplified ProductService handling both reads and writes:

public class ProductService
{
private readonly ApplicationDbContext _context;

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

// Write operation
public async Task<int> CreateProductAsync(string name, decimal price)
{
var product = new Product { Name = name, Price = price };
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product.Id;
}

// Read operation
public async Task<Product> GetProductByIdAsync(int id)
{
return await _context.Products.FindAsync(id);
}
}

In this setup, the service becomes cluttered as the application grows, and it’s hard to optimize or scale independently for reads and writes. This is where CQRS comes in to resolve these issues by separating read and write concerns, leading to simpler, more scalable, and maintainable code.

2. What Does CQRS Solve?

CQRS helps resolve these issues by separating the read and write operations into different models. This separation allows you to treat commands (writes) and queries (reads) independently, which can lead to several benefits:

  1. Simplified Logic: By segregating responsibilities, you create cleaner, more focused code. The write operations can handle the complex business logic, while read operations focus on efficient data retrieval.
  2. Optimized Performance: You can tune your database and services differently for queries and commands. For example, using a NoSQL database for fast reads and a relational database for precise and complex writes.
  3. Independent Scaling: In high-traffic applications, you may need to scale read operations far more than write operations. CQRS makes this easier by decoupling the two, allowing independent scalability.
  4. Improved Security: Commands and queries being separated can help implement more granular security policies (e.g., you can restrict writes to a subset of users while allowing broader access to reads).
  5. Easier Maintenance and Testing: With read and write logic decoupled, testing each side becomes much simpler. You can test read and write models independently, resulting in better maintainability.

When to Use CQRS:
CQRS is not necessary for every project. It shines in systems with high complexity, large-scale operations, or where read/write models need independent optimization.

3. Why Use the Mediator Pattern with CQRS?

In traditional architectures, components are often tightly coupled. Controllers call services, services call repositories, and everything is interconnected. This can quickly lead to a spaghetti codebase, where changes in one part affect the entire system.

The Mediator pattern comes in to decouple this mess by ensuring that objects communicate with each other through a mediator. It reduces direct dependencies between components and makes it easier to change one part of the system without affecting others.

In a CQRS context, the Mediator pattern (via the MediatR library) provides a clean and intuitive way to:

  • Dispatch commands and queries: It acts as a single entry point for sending commands and receiving query results.
  • Decouple handlers from the caller: Controllers no longer need to know about specific services or repositories. They simply pass commands and queries to the Mediator, and it handles routing them to the appropriate handler.
  • Centralized control flow: Cross-cutting concerns like logging, validation, and authorization can be handled at a single point (through pipeline behaviors in MediatR) rather than being scattered across various services.

The real power of using Mediator in a CQRS setup is the clean separation of concerns and how it promotes single-responsibility across your codebase.

4. Example: Command and Query Layer in Action

Let’s now look at how we can set up this pattern in ASP.NET Core. We will:

  1. Separate our commands (write operations) from queries (read operations).
  2. Use MediatR to handle command/query dispatching.

In this simplified example, let’s imagine we’re working with a Product model and need two operations:

  • CreateProductCommand (write)
  • GetProductByIdQuery (read)

Step 4.1: Defining the Product Model

public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}

Step 4.2: Defining a Command (CreateProductCommand)

The command encapsulates a write operation. By using a command, we clearly communicate that this operation will change the system’s state.

public class CreateProductCommand : IRequest<int>
{
public string Name { get; set; }
public decimal Price { get; set; }
}

Why does this matter?
This allows us to keep the write operation completely independent of any read logic. The business logic for creating a product will live in its own handler, simplifying the process.

Step 4.3: Defining a Query (GetProductByIdQuery)

The query is responsible for retrieving information without making any modifications to the system’s state.

public class GetProductByIdQuery : IRequest<Product>
{
public int Id { get; set; }
}

Benefit:
Queries are lightweight and optimized for reads. In CQRS, you can even choose different databases or approaches for queries than for commands, further enhancing performance for specific operations.

5. Integrating the Mediator Pattern

To effectively decouple our application logic using the Mediator pattern, we’ll leverage the MediatR library in ASP.NET Core. This allows us to route commands and queries to their respective handlers without the controller being directly responsible for the logic. But first, we need to make sure MediatR is properly configured.

Step 5.1: Install Required NuGet Packages

To use the MediatR library, you’ll need to install the necessary NuGet packages in your project. Run the following commands to add MediatR and related dependencies:

dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

Step 5.2: Register MediatR in the Dependency Injection Container

Next, we need to register MediatR in ASP.NET Core’s dependency injection (DI) container. In your Program.cs file (for .NET 6+ projects), add the following line to register MediatR:

using MediatR;
using System.Reflection;

var builder = WebApplication.CreateBuilder(args);

// Register MediatR
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(Assembly.GetExecutingAssembly()));

var app = builder.Build();
app.MapControllers();
app.Run();

This code registers all the command and query handlers in the current assembly, ensuring that ASP.NET Core can resolve the IMediator object wherever it’s injected.

Step 5.3: Using IMediator in the Controller

Now that MediatR is registered, we can inject the IMediator object into our controllers to decouple the handling of commands and queries.

Here’s an example of a ProductController that uses IMediator:

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

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

// Create a new product
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductCommand command)
{
var productId = await _mediator.Send(command);
return Ok(productId);
}

// Get a product by ID
[HttpGet("{id}")]
public async Task<IActionResult> GetProductById(int id)
{
var product = await _mediator.Send(new GetProductByIdQuery { Id = id });
return product == null ? NotFound() : Ok(product);
}
}

Key Benefit:

  • Controller simplicity: The controller doesn’t need to know about the services, data access, or even business logic. It simply sends commands and queries to the Mediator and gets back the result.
  • Loose coupling: The application becomes modular and easier to maintain as the business logic is decoupled from the controller.

6. Running and Testing the Application

Once the project is set up, run the application:

dotnet run

Use Postman or curl to test the API:

  • Create Product:
POST /api/product
{
"name": "Product Name",
"price": 99.99
}
  • Get Product:
GET /api/product/{id}

7. Wrapping It Up: Why CQRS and Mediator?

So what’s the big picture? By implementing CQRS with the Mediator pattern, you:

  • Increase flexibility: Read and write operations are optimized independently, allowing for better performance and scalability.
  • Simplify maintenance: Separating concerns makes your application easier to understand, test, and modify.
  • Promote clean architecture: The Mediator pattern ensures that your components communicate through well-defined contracts, making your application more modular and extensible.

Conclusion

CQRS and the Mediator pattern aren’t just buzzwords — they’re essential tools that help developers build more maintainable, scalable, and flexible systems. Whether your goal is to simplify complex logic, enhance performance, or improve your application’s testability, these patterns offer a solid foundation.

In the next part of this series, we’ll explore advanced topics like validation, event-driven architecture, and performance optimizations.

If you enjoyed this article and want more insights, be sure to follow Faisal Iqbal for regular updates on .NET and ASP.NET Core.

For those who want to dive deeper into these topics, check out my publication, .NET Insights: C# and ASP.NET Core, where we share tutorials, expert advice, and the latest trends in modern web development. Stay tuned for more!

--

--

F. I.
.NET Insights: C# and ASP.NET Core

Writes about event-driven architectures, distributed systems, garbage collection and other topics related to .NET and ASP.NET.