CQRS is a well-known architectural pattern that can solve many problems encountered in complex scenarios. Check out in this blog post how this pattern works and how to implement it in an ASP.NET Core application.
There are some situations where it is necessary to separate reading and writing functions, mainly in complex scenarios or that demand great scalability of resources. Imagine an ecommerce site where the data is read several times by a customer while they browse the site—however, writing only begins once the user adds an item to the cart. In this scenario, reading requires many more resources than writing. In traditional models, it is difficult to handle this in a simple way, as the reading and writing functions are processed in the same way.
Happily, there are intelligent solutions that solve these and other problems. One of the best known is the CQRS architectural pattern. Check out in this blog post what CQRS is and how to implement it in an ASP.NET Core application.
CQRS stands for Command and Query Responsibility Segregation, an architectural pattern for software development.
In CQRS, data-read operations are separated from data-write or update operations. This separation occurs in the interface or class where the read and write functions are kept.
Some of the advantages of using CQRS are:
The term Command Query Separation (CQS), which gave rise to CQRS, was defined by Bertrand Meyer in his book Object-Oriented Software Construction. In it, two well-defined layers are separated from each other:
So CQRS (introduced by Greg Young) is based on CQS but is more detailed.
It is common to find in modern and old systems traditional architectural patterns that use the same data model or DTO to query and persist/update data. When the system only uses a simple CRUD, this can be a great approach, but as the system grows and becomes complex it can become a real disaster.
In these scenarios, reading and writing have incompatibilities with each other, such as properties that are needed to update but should not be returned in queries. This difference can lead to data loss and, at best, break the architectural design of the application.
Therefore, the main objective of CQRS is to allow an application to work correctly using different data models, offering flexibility in scenarios that require a complex model. You have the possibility to create multiple DTOs without breaking any architectural pattern or losing any data in the process.
Next, we will implement CQRS in a .NET application. So, to create the project, just follow the next steps.
Prerequisites:
- .NET 6 SDK
- Fiddler Everywhere (to test the app)
You can access the repository with the source code used in the examples here: source code. (Note: This post was written with .NET 6, but .NET 7 is now available!)
First of all, let’s create the base application that will be a Minimal API, add the SQLite dependencies, and run the Migrations commands to create the database.
Then, follow the steps below:
Creating the project in Visual Studio
Creating via Terminal
dotnet new web -o ProductCatalog
Following are the project dependencies. You can add them in the file “ProductCatalog.csproj” or via NuGet.
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Let’s create the Product entity model. Add a new folder called “Models” and inside it add the following class:
Product
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ProductCatalog.Models;
public class Product
{
public int Id { get; set; }
[StringLength(80, MinimumLength = 4)]
public string? Name { get; set; }
[StringLength(80, MinimumLength = 4)]
public string? Description { get; set; }
[StringLength(80, MinimumLength = 4)]
public string? Category { get; set; }
public bool Active { get; set; } = true;
[Column(TypeName = "decimal(10,2)")]
public decimal Price { get; set; }
}
Let’s create the database context. Add a new folder called “Data” and inside it add the following class:
ProductDBContext
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Models;
namespace ProductCatalog.Data;
public class ProductDBContext : DbContext
{
public DbSet<Product> Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder options) =>
options.UseSqlite("DataSource=products.db;Cache=Shared");
}
And finally, in the Program.cs archive, add the following code to configure the context class.
builder.Services.AddDbContext<ProductDBContext>();
You can run the commands below in a project root terminal.
dotnet ef migrations add InitialModel
dotnet ef database update
Alternatively, run the following commands from the Package Manager Console in Visual Studio:
Add-Migration InitialModel
Update-Database
Now that the base application and the database are ready, we can apply the CQRS pattern to implement the CRUD methods, separating the query from the persistence.
But to help with this implementation, there is a very important feature called mediator. Check below what the mediator does.
The mediator pattern uses a very simple concept that perfectly fulfills its role: Provide a mediator class to coordinate the interactions between different objects and thus reduce the coupling and dependency between them.
In short, mediator makes a bridge between different objects, which eliminates the dependency between them as they do not communicate directly.
The diagram below demonstrates how the mediator works, indirectly linking objects A, B and C.
Pros:
Cons:
MediatR is a library created by Jimmy Bogard (also creator of AutoMapper) that helps in implementing the Mediator Pattern.
This library provides ready-made interfaces that serve as a mediating class for communication between objects—so when using MediatR, we don’t need to implement any of these classes, just use the resources available in MediatR.
To add MediatR to the project, just add the code below to the project’s dependencies or download it via Visual Studio’s NuGet Package.
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
Create a new folder called “Resources” and inside it create two new folders “Commands” and “Queries.”
Next, CQRS is used through the implementation of the query pattern composed of two objects:
Inside the Queries folder, create the following class:
GetProductByIdQuery
using MediatR;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Queries;
public class GetProductByIdQuery : IRequest<Product>
{
public int Id { get; set; }
}
Here we define a class that returns a Product object. Through it, we send a request to the mediator that will execute the query.
The next class will execute the query and return the product, so inside the Queries folder adds the class below:
GetProductByIdQueryHandler
using MediatR;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Queries;
public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, Product>
{
private readonly ProductDBContext _context;
public GetProductByIdQueryHandler(ProductDBContext context)
{
_context = context;
}
public async Task<Product> Handle(GetProductByIdQuery request, CancellationToken cancellationToken) =>
await _context.Products.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
}
Inside the Queries folder, create the class below:
GetAllProductsQuery
using MediatR;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Queries;
public class GetAllProductsQueryHandler : IRequestHandler<GetAllProductsQuery, IEnumerable<Product>>
{
private readonly ProductDBContext _context;
public GetAllProductsQueryHandler(ProductDBContext context)
{
_context = context;
}
public async Task<IEnumerable<Product>> Handle(GetAllProductsQuery request, CancellationToken cancellationToken) =>
await _context.Products.ToListAsync();
}
Next, CQRS is used through the implementation of the command pattern composed of two objects:
The commands will execute the Create/Update/Delete persistence methods.
All command classes implement the IRequest<T>
interface, where the type of data to be returned is specified. This way, MediatR knows which ver
object is invoked during a request.
So, inside the “Commands” folder, create a new folder called “Create” and inside it, create the classes below:
CreateProductCommand
using MediatR;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Commands.Create;
public class CreateProductCommand : IRequest<Product>
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? Category { get; set; }
public bool Active { get; set; } = true;
public decimal Price { get; set; }
}
CreateProductCommandHandler
using MediatR;
using ProductCatalog.Data;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Commands.Create;
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Product>
{
private readonly ProductDBContext _dbContext;
public CreateProductCommandHandler(ProductDBContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Product> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Category = request.Category,
Price = request.Price,
};
_dbContext.Products.Add(product);
await _dbContext.SaveChangesAsync();
return product;
}
}
Then, create a new folder called “Update” and inside it, create the classes below:
UpdateProductCommand
using MediatR;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Commands.Update;
public class UpdateProductCommand : IRequest<Product>
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? Category { get; set; }
public bool Active { get; set; } = true;
public decimal Price { get; set; }
}
UpdateProductCommandHandler
using MediatR;
using ProductCatalog.Data;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Commands.Update
{
public class UpdateProductCommandHandler : IRequestHandler<UpdateProductCommand, Product>
{
private readonly ProductDBContext _dbContext;
public UpdateProductCommandHandler(ProductDBContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Product> Handle(UpdateProductCommand request, CancellationToken cancellationToken)
{
var product = _dbContext.Products.FirstOrDefault(p => p.Id == request.Id);
if (product is null)
return default;
product.Name = request.Name;
product.Description = request.Description;
product.Category = request.Category;
product.Price = request.Price;
await _dbContext.SaveChangesAsync();
return product;
}
}
}
Then, create a new folder called “Delete” and, inside it, create the classes below:
DeleteProductCommand
using MediatR;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Commands.Delete;
public class DeleteProductCommand : IRequest<Product>
{
public int Id { get; set; }
}
DeleteProductCommandHandler
using MediatR;
using ProductCatalog.Data;
using ProductCatalog.Models;
namespace ProductCatalog.Resources.Commands.Delete;
public class DeleteProductCommandHandler : IRequestHandler<DeleteProductCommand, Product>
{
private readonly ProductDBContext _dbContext;
public DeleteProductCommandHandler(ProductDBContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Product> Handle(DeleteProductCommand request, CancellationToken cancellationToken)
{
var product = _dbContext.Products.FirstOrDefault(p => p.Id == request.Id);
if (product is null)
return default;
_dbContext.Remove(product);
await _dbContext.SaveChangesAsync();
return product;
}
}
In the Program.cs archive, add the following code line:
builder.Services.AddMediatR(Assembly.GetExecutingAssembly());
Still in the Program.cs archive, add the following code to configure the API endpoints:
app.MapGet("product/get-all", async (IMediator _mediator) =>
{
try
{
var command = new GetAllProductsQuery();
var response = await _mediator.Send(command);
return response is not null ? Results.Ok(response) : Results.NotFound();
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});
app.MapGet("product/get-by-id", async (IMediator _mediator, int id) =>
{
try
{
var command = new GetProductByIdQuery() { Id = id };
var response = await _mediator.Send(command);
return response is not null ? Results.Ok(response) : Results.NotFound();
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});
app.MapPost("product/create", async (IMediator _mediator, Product product) =>
{
try
{
var command = new CreateProductCommand()
{
Name = product.Name,
Description = product.Description,
Category = product.Category,
Price = product.Price,
Active = product.Active,
};
var response = await _mediator.Send(command);
return response is not null ? Results.Ok(response) : Results.NotFound();
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});
app.MapPut("product/update", async (IMediator _mediator, Product product) =>
{
try
{
var command = new UpdateProductCommand()
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Category = product.Category,
Price = product.Price,
Active = product.Active,
};
var response = await _mediator.Send(command);
return response is not null ? Results.Ok(response) : Results.NotFound();
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});
app.MapDelete("product/delete", async (IMediator _mediator, int id) =>
{
try
{
var command = new DeleteProductCommand() { Id = id };
var response = await _mediator.Send(command);
return response is not null ? Results.Ok(response) : Results.NotFound();
}
catch (Exception ex)
{
return Results.BadRequest(ex.Message);
}
});
The GIF below demonstrates the execution of the project via Fiddler Everywhere, a secure web debugging proxy for any platform. You can see some CRUD functions executing perfectly as expected.
CQRS is a development standard that brings many advantages, such as the possibility for separate teams to work on the read and persistence layer and also to be able to scale database resources as needed.
Understanding how CQRS works and how to implement it in an application allows you to do very well when the need arises to use it in some project.