Learn Advanced CQRS with .NET Core

Introduction

Command Query Responsibility Segregation (CQRS) is a pattern that separates the responsibility of reading data (queries) from the responsibility of modifying data (commands). This separation allows for more scalable, maintainable, and performance-optimized applications. In this article, we will explore an advanced implementation of CQRS using .NET Core and the MediatR library. We’ll walk through the architecture, its benefits, and practical code examples in C#.

1. Understanding CQRS and Its Benefits
 

What is CQRS?

CQRS is a design pattern that divides an application's operations into two distinct types.

  • Commands: Operations that change the state of the system (e.g., create, update, delete).
  • Queries: Operations that retrieve data without changing the state.

Key Benefits of CQRS

  • Separation of Concerns: By separating commands and queries, you can optimize and scale each part independently.
  • Improved Performance: Read and write operations can be optimized separately, leading to better overall system performance.
  • Scalability: CQRS enables easier horizontal scaling, especially in systems with complex data models or high read/write loads.
  • Enhanced Maintainability: The clear separation of responsibilities simplifies the codebase, making it easier to maintain and extend.

2. Setting Up Your .NET Core Project with MediatR
 

Creating a New .NET Core Project

Start by creating a new .NET Core project.

dotnet new webapi -n AdvancedCQRS
cd AdvancedCQRS

Install the MediatR package and its extensions to handle CQRS.

dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

3. Implementing the CQRS Pattern with MediatR
 

Defining Commands and Command Handlers

Let’s define a command for creating a new product in an e-commerce system.

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

Next, we create a handler for this command.

public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
{
    private readonly ApplicationDbContext _context;

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

    public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        var product = new Product
        {
            Name = request.Name,
            Price = request.Price,
            Category = request.Category
        };

        _context.Products.Add(product);
        await _context.SaveChangesAsync();

        return product.Id;
    }
}

Defining Queries and Query Handlers

Now, let’s define a query to retrieve product details.

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

The handler for this query.

public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
{
    private readonly ApplicationDbContext _context;

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

    public async Task<ProductDto> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
    {
        var product = await _context.Products.FindAsync(request.Id);

        if (product == null) return null;

        return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price,
            Category = product.Category
        };
    }
}

4. Configuring the CQRS Handlers in .NET Core

To integrate MediatR and configure your CQRS handlers, you need to update the `Startup.cs` file.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();

        // Register MediatR
        services.AddMediatR(typeof(Startup));

        // Register your DbContext
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

        // Register other services
        services.AddTransient<IRequestHandler<CreateProductCommand, int>, CreateProductCommandHandler>();
        services.AddTransient<IRequestHandler<GetProductByIdQuery, ProductDto>, GetProductByIdQueryHandler>();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

5. Expanding Your CQRS Implementation

  • Event Sourcing and CQRS: Event sourcing is a natural complement to the CQRS pattern, as it allows you to store changes to the system state as a series of events. These events can then be replayed to rebuild the system state, making it easier to audit and troubleshoot issues.
  • Handling Cross-Cutting Concerns: Consider using MediatR’s pipeline behaviors to handle cross-cutting concerns like validation, logging, and transaction management. For example, you can create a validation pipeline that ensures all commands are validated before they are processed.
    public class ValidationPipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    {
        private readonly IEnumerable<IValidator<TRequest>> _validators;
    
        public ValidationPipelineBehavior(IEnumerable<IValidator<TRequest>> validators)
        {
            _validators = validators;
        }
    
        public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
        {
            var context = new ValidationContext<TRequest>(request);
            var failures = _validators
                .Select(v => v.Validate(context))
                .SelectMany(result => result.Errors)
                .Where(f => f != null)
                .ToList();
    
            if (failures.Any())
            {
                throw new ValidationException(failures);
            }
    
            return await next();
        }
    }
    
  • Optimizing Read and Write Models: In a CQRS architecture, the read and write models can be optimized independently. For instance, you can use a separate database schema or a different type of database (e.g., NoSQL) for read operations to improve query performance.

6. Best Practices for Implementing CQRS

  • Consistency and Eventual Consistency: In a distributed system, achieving strong consistency across microservices can be challenging. CQRS often embraces eventual consistency, where the system eventually reaches a consistent state after a command is processed.
  • Error Handling and Resilience: Ensure that your system can gracefully handle errors and failures. Use retry mechanisms, circuit breakers, and other resilience patterns to make your system robust against transient failures.
  • Security Considerations: Implement proper security measures to protect your CQRS endpoints. This includes authentication, authorization, and input validation to prevent common vulnerabilities like injection attacks.

Conclusion

Implementing advanced CQRS with .NET Core and MediatR allows you to build scalable, maintainable, and performant applications. By separating commands and queries, you can optimize each aspect of your application independently, making it easier to handle complex business requirements. The code examples provided in this article should give you a solid foundation for implementing CQRS in your own .NET Core projects. As you continue to develop your architecture, consider incorporating event sourcing, pipeline behaviors, and other advanced techniques to further enhance your system.