Implementing CQRS And Mediator Patterns With ASP.NET Core Web API

CQRS Pattern

CQRS stands for Command Query Responsibility Segregation. It segregates the read and write operations on a data store into queries and commands respectively. Queries perform read operations and return the DTOs without modifying any data. Commands perform writes, updates, or delete operations on the data. This distinction helps manage data access complexity in large applications by making them more decoupled and testable. Moreover, this distinction also helps to scale the read and write operations independent of each other to match skewed workloads and have separate data schemes optimized for each operation type. However, applications with simple data access requirements might not benefit from CQRS as it may add additional complexity.

Mediator Pattern

Mediator pattern helps manage dependencies between objects by preventing them from directly communicating with each other. Instead, the communication happens through a mediator. For example, a service sends its request to the mediator that in turn forwards it to the respective request handler for processing. By having a mediator in between, we can decouple requests from their handlers. The sender does not need to know anything about the handler. Mediator pattern helps implement CQRS. Commands and queries are sent to the mediator that maps them to their respective handlers.

In this article, we will go through a simple implementation of these patterns in ASP.NET Core 6.0 WebAPI using an in-process messaging library called MediatR.

Implementing CQRS And Mediator Patterns With ASP.NET Core Web API

Start with adding the following Nuget packages to ASP.NET Core 6.0 WebAPI project:-

  • MediatR
  • MediatR.Extensions.Microsoft.DependencyInjection

We will assume that our Web API will interact with a Products data store. Each Product can have an Id, Title, and Quantity. We start with defining the model. For production scenarios, it is always advisable to keep the domain and API contracts as separate models. To keep things simple, we may slightly deviate from this and define and use a single Product model as follows.

public class Product
{
    public int Id { get; set; }
    public string Title { get; set; }
    public int Qunatity { get; set; }
}

We add an additional class for adding a new Product called AddProduct.cs

public class AddProduct
{
    public string Title { get; set; }
    public int Quantity { get; set; }
}

Add MediatR to our project’s DI container and pass the assembly that would contain the commands, queries, and handlers that we will write subsequently to work with our data. ASP.NET Core 6 does not have a Startup.cs class, instead, it's merged with Program.cs. Hence the service is added to DI container inside Program.cs as follows. If you are using the previous version of ASP.NET Core, add the service to the ConfigureServices method inside Startup.cs class

builder.Services.AddMediatR(typeof(Program).Assembly);

Let’s start by adding our first query to get all products form the data store. Create a new class called GetAllProductsQuery.cs that implements MediatR's IRequest<TResponse> interface where TResponse is the type of data that will be returned by the query. You can even use C# 10 records to define queries (In fact, records might be a better choice here as they are immutable by default). For the purpose of this article, we will stick to classes.

public class GetAllProductsQuery : IRequest<List<Product>> {}

Next, we define a handler by creating a new class called GetAllProductsQueryHandler.cs that implements IRequestHandler<TRequest, TResponse> where TRequest is the query type that the handler will handle and TResponse is the response type that will be returned by the handler. We implement the logic to fetch and return the data from data store inside the Handle method. We can access the data store by using the IProductsRepo that is injected to this class via Dependency Injection (Make sure to add your repository service to DI container in Program.cs as per the type of data store you are using).

public class GetAllProductsQueryHandler : IRequestHandler<GetAllProductsQuery, List<Product>>
{
    private readonly IProductsRepo _productsRepo;

    public GetAllProductsQueryHandler(IProductsRepo productsRepo)
    {
        _productsRepo = productsRepo;
    }

    public async Task<List<Product>> Handle(GetAllProductsQuery request, CancellationToken cancellationToken)
    {
        var products = await _productsRepo.GetAll();
        return products;
    }
}

We have the query and its handler created. Next, we need to wire this in our controller. Inside the ProductsController, we use the MediatR injected ISender that will allow us to send queries and receive response.

[ApiController]
[Route("[controller]/[action]")]
public class ProductsController : Controller
{
    private readonly ISender _mediator;

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

    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var products = await _mediator.Send(new GetAllProductsQuery());
        return Ok(products);
    }
}

When we want to pass a parameter while issuing a query, we can add that as property of the query class. For example, when we want to fetch product by its id, the query class looks like follows.

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

    public GetProductByIdQuery(int id)
    {
        Id = id;
    }
}

The handler for the above query can access the query properties through the request object inside the Handle method.

public class GetProductByIdHandler : IRequestHandler<GetProductByIdQuery, Product>
{
	private readonly IProductsRepo _productsRepo;

	public GetProductByIdHandler(IProductsRepo productsRepo)
	{
		_productsRepo = productsRepo;
	}

    public async Task<Product> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
    {
		var product = await _productsRepo.GetById(request.Id);
		return product;
    }
}

We can issue the query through the ProductsController as follows.

[HttpGet]
public async Task<IActionResult> GetById(int id)
{
    var product = await _mediator.Send(new GetProductByIdQuery(id));
    return product != null ? Ok(product) : NotFound();
}

Notice how this pattern allows us to write very thin controllers by distributing the data access and update logic between the command and query handlers. The controllers are also decoupled from the repository and have no idea about the handlers. Another advantage of using this pattern is that it also helps better manage the dependency injection in the classes by enabling to include only dependencies required to carry out an operation within the handler.

We have seen couple of examples of queries in previous steps. Let’s add a command class called AddProductCommand.cs to create a new product as follows.

public class AddProductCommand : IRequest<Product>
{
    public string Title { get; }
    public int Quantity { get; }

    public AddProductCommand(string title, int quantity)
    {
        Title = title;
        Quantity = quantity;
    }
}

Add a new handler for the above command.

public class AddProductHandler : IRequestHandler<AddProductCommand, Product>
{
    private readonly IProductsRepo _productsRepo;

    public AddProductHandler(IProductsRepo productsRepo)
    {
        _productsRepo = productsRepo;
    }

    public async Task<Product> Handle(AddProductCommand request, CancellationToken cancellationToken)
    {
        return await _productsRepo.Add(request.Title, request.Quantity);
    }
}

And invoke it from the controller as follows.

[HttpPost]
public async Task<IActionResult> Add(AddProduct newProduct)
{
    var product = await _mediator.Send(new AddProductCommand(newProduct.Title, newProduct.Quantity));
    return product != null ? Created($"/product/{product.Id}", product) : BadRequest();
}

In conclusion, CQRS and Mediator patterns are very effective in managing the data access complexity of applications and can help in writing code that is more testable and manageable. It comes with its own set of drawbacks like inability to scaffold models using ORM tools and set of additional classes required to segregate data models and read/write operations. Hence decision to use this pattern depends largely on the size, scale and complexity of the application and its related data access patterns.

References

https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs

https://github.com/jbogard/MediatR

https://www.youtube.com/watch?v=yozD5Tnd8nw