Post

Understanding CQRS in .NET

Understanding CQRS in .NET

CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates the responsibilities of reading (Query) and writing (Command) operations in an application. This separation can simplify the design, improve performance, scalability, and facilitate data management in complex systems. In this post, we’ll explore how to implement CQRS in a .NET project.
CQRS

What is CQRS?

The CQRS pattern splits the logic of a system into two distinct parts:

  • Commands: Modify the application’s state (such as creating, updating, or deleting data).
  • Queries: Retrieve data without modifying the state. This separation allows for optimizing each responsibility independently, which can be particularly useful in high-performance applications or systems managing complex data.

When to use CQRS??

CQRS is beneficial in scenarios where user experience and performance are critical. Here are key situations for implementing CQRS:

  • High User Traffic: For websites expecting heavy traffic, such as online stores, since customers typically browse many more products than they purchase. Separating read and write operations allows independent scaling, keeping browsing and search functionalities responsive during peak loads.
  • Complex Business Logic: In applications with intricate workflows, CQRS separates reading and executing commands, making the system easier to maintain and evolve.
  • Enhanced User Experience: If quick access to product details and inventory levels is a priority, CQRS optimizes read operations for speed, reducing loading times and improving user satisfaction.
  • Event-Driven Architectures: CQRS supports event-driven approaches, allowing commands to trigger domain events for notifications or logging without affecting core logic.
  • Microservices Architecture: In microservices setups, CQRS enables independent management of read and write operations across services.
  • Need for Data Consistency: For scenarios requiring data consistency, such as order placements, CQRS ensures transactional handling of writes while keeping reads efficient.

CQRS is an excellent architectural choice for online stores and applications focused on performance and scalability. It helps create a robust and flexible system that meets modern software demands.
CQRS for Online Store

NOTE: Splitting projects into separate read and write components is not mandatory; you can organize them into different folders within the same project. However, I highly recommend separating them to leverage scalability benefits when deploying on Kubernetes.
Similarly, having distinct databases for reading and writing is not strictly required. That said, read-optimized databases are designed for efficient query performance and can be scaled independently to meet demand.

Benefits of CQRS

  1. Scalability: You can scale queries and commands independently. For example, you could replicate read databases to better distribute query loads.
  2. Simplified queries: Queries can be optimized for performance without worrying about update implications.
  3. Flexibility: It allows using different data models for reading and writing, improving efficiency.

How to Separate Read and Write Operations in CQRS

  1. Create Distinct Projects: Establish separate projects for handling read and write operations. For instance, you might have a Read Project that focuses on querying data and a Write Project dedicated to processing commands. This separation allows for independent development, deployment, and scaling.
  2. Choose Appropriate Storage Solutions: Utilize different storage solutions that suit the needs of each operation. The read project might benefit from a NoSQL database or an in-memory cache for fast access, while the write project could use a relational database to ensure data consistency and integrity.
  3. Establish Communication Patterns: Set up communication mechanisms between the projects. The write project can publish domain events when data changes, allowing the read project to update its model accordingly. This event-driven approach helps maintain synchronization while decoupling the services. However, it might not be needed if you are using scalable databases such as MongoDB, which benefits from read-only replica sets that can serve queries.
    CQRS with MongoDb
  4. Monitor and Optimize: Continuously monitor the performance of both projects. As usage patterns evolve, be prepared to optimize or scale each project independently to meet changing demands.

How to implement Query/Read operations

The following example demonstrates how to implement CQRS alongside Clean Architecture. Let’s start by looking at the recommended solution structure:
CQRS solution tree
Notice there are two separate API projects, allowing for independent scaling of read and write operations.

Now, let’s go through the flow for query/read operations:

Presentation

CQRS read presentation
We’ll define an API endpoint to retrieve information about all games whose names contain the specified string.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class GetGamesByNameConsolesModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("api/GamesByName/{GameName}", async (IMediator mediator, string gameName) =>
        {
            var query = new GetGamesByNameQuery(gameName);
            var result = await mediator.Send(new GetGamesByNameQuery(gameName));

            return result.IsSuccess ?
                Results.Ok(result.Value.ToGetGamesByNameResponse()) :
                Results.BadRequest(result.Error);
        })
    }
}

Here, we are using Minimal APIs along with MediatR. Let’s break down the steps involved:

  1. Receive the Request with the parameter string gameName.
  2. Create a Query object: var query = new GetGamesByNameQuery(gameName);.
  3. Send the query object via MediatR to the Application Layer: await mediator.Send(query);.
  4. Return the result, either as Ok or BadRequest based on success.

Application

CQRS read application
The Query and Response classes are simple models with no internal logic. They only define the data structure for input and output. Adding a Validator is optional if you wish to validate input data before processing.

The core logic resides in the Handler, where the query processing is managed:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class GetGamesByNameHandler(
    IGameReadRepository gameReadRepository) : IRequestHandler<GetGamesByNameQuery, IResult<GetGamesByNameQueryResponse>>
{
    private readonly IGameReadRepository _gameReadRepository = gameReadRepository;

    public async Task<IResult<GetGamesByNameQueryResponse>> Handle(GetGamesByNameQuery query, CancellationToken cancellationToken)
    {
        var games = await _gameReadRepository.GetGamesByName(query.GameName, cancellationToken);
        // Apply business rules (if any)...

        return Result.Success(games.ToGetGamesByNameQueryResponse());
    }
}

The Handle method will automatically receive any GetGamesByNameQuery sent via MediatR. Here, data is fetched from the Infrastructure layer, business rules (if any) are applied, and a Response object is returned.

Both input and output objects should be immutable, which you can easily achieve using records:

1
public record GetGamesByNameQuery(string GameName) : IRequest<IResult<GetGamesByNameQueryResponse>>;
1
public record GetGamesByNameQueryResponse(IReadOnlyCollection<GetGamesByNameQueryResponseItem> Games);
1
2
3
4
5
6
7
public record GetGamesByNameQueryResponseItem(
    int Id,
    string Name,
    string Publisher,
    double Price,
    int GameConsoleId,
    string GameConsoleName);

Infrastructure

CQRS read infrastructure
In the Infrastructure layer, we define classes to retrieve data, such as a repository. Here’s an example repository implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class GameReadRepository(
    IDbContextFactory<ReadOnlyDatabaseContext> readOnlyDatabaseContextFactory) : IGameReadRepository
{
    private readonly IDbContextFactory<ReadOnlyDatabaseContext> _readOnlyDatabaseContextFactory = readOnlyDatabaseContextFactory;

    public async Task<IReadOnlyCollection<Game>> GetGamesByName(string gameName, CancellationToken cancellationToken)
    {
        var readOnlyDbContext = await _readOnlyDatabaseContextFactory.CreateDbContextAsync(cancellationToken);

        return await readOnlyDbContext
            .Games
            .Include(g => g.GameConsole)
            .Where(g => EF.Functions.Like(g.Name, $"%{gameName}%"))
            .ToArrayAsync(cancellationToken);
    }
    // More read methods.... 
}

This is a typical repository fetching data from the database, focusing on read-only operations to optimize performance.

Domain

CQRS domain
The Domain layer contains the core business models used across the application and interfaces that help abstract connections between layers.

For example, the Game object might look like this:

1
2
3
4
5
6
7
8
9
public class Game
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Publisher { get; set; }
    public required Price Price { get; set; }
    public required int GameConsoleId { get; set; }
    public virtual GameConsole? GameConsole { get; set; }
}

This model can be shared across both read and write operations, ensuring consistency.

How to implement Command/Write operations

Presentation

CQRS write presentation
We’ll define an API endpoint to add a new Game Console to the system:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class AddGameConsoleModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("api/AddGameConsole", async (IMediator mediator, AddGameConsoleRequest request, CancellationToken cancellationToken) =>
        {
            var command = request.ToCommand();
            var result = await mediator.Send(command, cancellationToken);

            return result.IsSuccess ?
                Results.Created() :
                Results.BadRequest(result.Error);
        })
    }
}

As for Query operations, we are using Minimal APIs along with MediatR. Let’s break down the steps involved:

  1. Receive the Request with the parameter AddGameConsoleRequest.
  2. Create a Command object (using a mapper in this case): var command = request.ToCommand();.
  3. Send the command object via MediatR to the Application Layer: await mediator.Send(command, cancellationToken);.
  4. Return the result, either as Created or BadRequest based on success.

Application

1
2
3
4
5
6
7
8
9
10
11
12
13
public class AddGameConsoleHandler(
    IGameConsoleWriteRepository gameConsoleWriteRepository) : IRequestHandler<AddGameConsoleCommand, IResult<AddGameConsoleCommandResponse>>
{
    private readonly IGameConsoleWriteRepository _gameConsoleWriteRepository = gameConsoleWriteRepository;

    public async Task<IResult<AddGameConsoleCommandResponse>> Handle(AddGameConsoleCommand command, CancellationToken cancellationToken)
    {
        var gameConsole = command.ToDomain();
        await _gameConsoleWriteRepository.AddGameConsole(gameConsole, cancellationToken);

        return Result.Success(gameConsole.ToAddGameConsoleCommandResponse());
    }
}

The Handle method will automatically receive any AddGameConsoleCommand sent via MediatR. Here, data is sent to the Infrastructure layer, business rules (if any) are applied, and a Response object is returned.

Both input and output objects should be immutable, which you can easily achieve using records:

1
2
3
4
public record AddGameConsoleCommand(
    string Name,
    string Manufacturer,
    double Price) : IRequest<IResult<AddGameConsoleCommandResponse>>;
1
2
3
public record AddGameConsoleCommandResponse(
    int Id,
    string Name);

Infrastructure

CQRS write infrastructure
In the Infrastructure layer, we define classes to save data, such as a repository. Here’s an example repository implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class GameConsoleWriteRepository(
    IDbContextFactory<WriteReadDatabaseContext> writeReadDbContextFactory) : IGameConsoleWriteRepository
{
    private readonly IDbContextFactory<WriteReadDatabaseContext> _writeReadDbContextFactory = writeReadDbContextFactory;

    public async Task AddGameConsole(GameConsole gameConsole, CancellationToken cancellationToken)
    {
        var writeReadDbContext = await _writeReadDbContextFactory.CreateDbContextAsync(cancellationToken);
        await writeReadDbContext.AddAsync(gameConsole, cancellationToken);
        await writeReadDbContext.SaveChangesAsync(cancellationToken);
    }
    // More write operations...
}

This is a typical repository setup for saving data to the database.

Domain

The Domain layer is shared between both read and write operations. Here is an example of a GameConsole entity:

1
2
3
4
5
6
7
public class GameConsole
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Manufacturer { get; set; }
    public required double Price { get; set; }
}

Conclusion

CQRS is a powerful pattern that helps to simplify complex systems by separating concerns related to reading and writing data. In .NET, by adopting this pattern, you can improve the scalability, flexibility, and maintainability of your applications.

GitHub CQRS
This project provides a robust solution using CQRS, along with other patterns such as Clean Architecture and DDD.

This post is licensed under CC BY 4.0 by the author.