CQRS Made Easy with MediatR in C#

CQRS is an architectural pattern that separates the read and write operations of an application into two distinct models, called Command and Query respectively. This separation allows each model to be optimized for its specific use case, resulting in a more scalable and maintainable application.

In this article, we’ll explore the basics of CQRS, its benefits, implementation considerations, and provide some code examples in C# using the MediatR library.

The Basics of CQRS

At its core, CQRS is a pattern that emphasizes the separation of concerns between the read and write operations of an application. In a traditional application architecture, both read and write operations are handled by the same model, which can lead to a number of issues, including performance problems, scaling challenges, and difficulty in maintaining the codebase.

With CQRS, the write operations (i.e., creating, updating, and deleting data) are handled by a separate model called the Command model. The read operations (i.e., retrieving data) are handled by a separate model called the Query model.

In this way, each model can be optimized for its specific use case. For example, the Command model might use a different database schema or architecture than the Query model, since the focus of write operations is typically on consistency and durability, while the focus of read operations is on performance and scalability.

CQRS also provides a clear separation of concerns, which can make the codebase easier to understand and maintain. Since each model is responsible for a specific set of operations, developers can more easily reason about how changes to the codebase will affect the overall system.

Benefits of CQRS

CQRS offers a number of benefits for application development, including:

Scalability

CQRS allows the read and write operations to be scaled independently, since they are handled by separate models. This means that read-heavy applications can be scaled more easily without impacting the write operations, and vice versa.

Performance

By optimizing each model for its specific use case, CQRS can lead to significant performance improvements. The Query model can be designed for fast data retrieval, while the Command model can be optimized for consistency and durability.

Separation of Concerns

CQRS provides a clear separation of concerns, making the codebase easier to understand and maintain. Developers can more easily reason about how changes to the codebase will affect the overall system.

Flexibility

CQRS allows each model to evolve independently, since they are not tightly coupled. This means that changes to one model can be made without impacting the other model.

Implementation Considerations

While CQRS offers a number of benefits, there are some implementation considerations to keep in mind when using this pattern:

Increased Complexity

CQRS can increase the complexity of the application, since there are now two distinct models to maintain. This can lead to a steeper learning curve for developers and a more complex codebase overall.

Eventual Consistency

Since the Command and Query models are separate, there may be a delay between when data is written and when it becomes available for reading. This delay is known as eventual consistency and must be accounted for in the application design.

Data Duplication

Since the Command and Query models are separate, there may be some duplication of data between them. This can lead to increased storage requirements and may require additional effort to keep the data in sync.

MediatR as a CQRS library

One popular library for implementing CQRS in C# is MediatR. MediatR is a simple and lightweight library that provides a mediator pattern implementation, allowing for easy separation of Command and Query models.

Mediator pattern implementation, allowing for easy separation of Command and Query models

Using MediatR to Implement CQRS

Installing MediatR

To get started, we need to install the MediatR library using NuGet. We can do this by running the following command in the Package Manager Console:

Install-Package MediatR

Creating Commands

In CQRS, Commands are used to represent write operations. To create a Command, we need to define a class that inherits from the IRequest interface. Here’s an example:

public class CreateUserCommand : IRequest<int>
{
    public string Name { get; set; }
    public string Email { get; set; }
}

In this example, we’ve defined a CreateUserCommand that takes a name and an email address. The IRequest<int> interface specifies that this command will return an integer value once it has been executed.

Creating Command Handlers

Commands are executed by Command Handlers, which implement the IRequestHandler interface. Here’s an example:

public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, int>
{
    private readonly DbContext _dbContext;

    public CreateUserCommandHandler(DbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<int> Handle(CreateUserCommand request, CancellationToken cancellationToken)
    {
        var user = new User
        {
            Name = request.Name,
            Email = request.Email
        };

        _dbContext.Users.Add(user);

        await _dbContext.SaveChangesAsync(cancellationToken);

        return user.Id;
    }
}

In this example, we’ve defined a CreateUserCommandHandler that takes a DbContext as a dependency. When executed, the handler creates a new User object, adds it to the database, and returns the ID of the newly created user.

Creating Queries

Queries are used to represent read operations. To create a Query, we need to define a class that inherits from the IRequest interface. Here’s an example:

public class GetUsersQuery : IRequest<List<UserDto>>
{
}

In this example, we’ve defined a GetUsersQuery that retrieves a list of users from the database. The IRequest<List<UserDto>> interface specifies that this query will return a list of UserDto objects once it has been executed.

Creating Query Handlers

Queries are executed by Query Handlers, which implement the IRequestHandler interface. Here’s an example:

public class GetUsersQueryHandler : IRequestHandler<GetUsersQuery, List<UserDto>>
{
    private readonly DbContext _dbContext;

    public GetUsersQueryHandler(DbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<List<UserDto>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
    {
        var users = await _dbContext.Users
            .Select(u => new UserDto
            {
                Id = u.Id,
                Name = u.Name,
                Email = u.Email
            })
            .ToListAsync(cancellationToken);

        return users;
    }
}

In this example, we’ve defined a GetUsersQueryHandler that takes a DbContext as a dependency. When executed, the handler retrieves a list of UserDto objects from the database and returns them.

Wiring Up MediatR

Now that we’ve defined our Commands and Queries, and their respective Handlers, we need to wire up MediatR to use them. We can do this by adding the following code to our startup configuration:

services.AddMediatR(typeof(Startup).Assembly);</code>

This tells MediatR to scan the assembly containing our Startup class for command and query handlers and register them with the MediatR pipeline.

Using MediatR to Execute Commands and Queries

To execute a Command or Query using MediatR, we need to create an instance of the appropriate class and pass it to the IMediator.Send method. Here’s an example:

public class UsersController : Controller
{
    private readonly IMediator _mediator;

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

    [HttpPost]
    public async Task<IActionResult> CreateUser(CreateUserCommand command)
    {
        var userId = await _mediator.Send(command);

        return Ok(userId);
    }

    [HttpGet]
    public async Task<IActionResult> GetUsers()
    {
        var users = await _mediator.Send(new GetUsersQuery());

        return Ok(users);
    }
}

In this example, we’ve defined an UsersController that takes an IMediator as a dependency. The CreateUser action method creates a new CreateUserCommand instance and passes it to the IMediator.Send method. The GetUsers action method creates a new GetUsersQuery instance and passes it to the same method.

When MediatR receives a Command or Query, it looks for the appropriate Handler and passes the request object to its Handle method. Once the Handler has finished executing, it returns the result back to the MediatR pipeline, which passes it back to the Controller.

Benefits of Using CQRS with MediatR

Using CQRS with MediatR offers several benefits, including:

  • Separation of concerns: Commands and Queries are separated from the business logic, which improves the readability and maintainability of the codebase.
  • Testability: Commands and Queries can be easily tested in isolation, which improves the overall test coverage and reduces the likelihood of bugs.
  • Scalability: By separating read and write operations, CQRS can improve the scalability of the application, as read-heavy and write-heavy operations can be scaled independently.

Conclusion

In this article, we’ve explored the basics of CQRS and how to implement it using MediatR. We’ve seen how to define Commands and Queries, create Command and Query Handlers, and wire up MediatR to execute them. We’ve also discussed the benefits of using CQRS with MediatR, including improved separation of concerns, testability, and scalability.

CQRS can be a powerful tool in the software architect’s toolbox, but it’s important to use it judiciously. Not every application will benefit from using CQRS, and it’s important to consider the tradeoffs involved before committing to this architectural pattern. However, if used correctly, CQRS can help you build scalable, maintainable, and testable applications.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *