Skip to content

CQRS Pattern

GrydCrud supports the CQRS (Command Query Responsibility Segregation) pattern using MediatR.

Overview

CQRS separates read and write operations into different models:

  • Commands - Operations that modify state (Create, Update, Delete)
  • Queries - Operations that read state (GetById, Query, List)
┌────────────────────────────────────────────────────────────────────────┐
│                           API Controller                                │
│                  CrudController<TEntity, TResultDto>                   │
└────────────────────┬───────────────────────────────────────┬───────────┘
                     │                                       │
                     ▼                                       ▼
┌────────────────────────────────────┐   ┌────────────────────────────────┐
│            Commands                │   │            Queries             │
│  ┌──────────────────────────────┐  │   │  ┌──────────────────────────┐  │
│  │     CreateEntityCommand      │  │   │  │    GetEntityByIdQuery    │  │
│  │     UpdateEntityCommand      │  │   │  │      QueryEntitiesQuery  │  │
│  │     DeleteEntityCommand      │  │   │  └──────────────────────────┘  │
│  └──────────────────────────────┘  │   │                                │
└──────────────────┬─────────────────┘   └────────────────┬───────────────┘
                   │                                      │
                   ▼                                      ▼
┌────────────────────────────────────┐   ┌────────────────────────────────┐
│       Command Handlers             │   │        Query Handlers          │
│  ┌──────────────────────────────┐  │   │  ┌──────────────────────────┐  │
│  │  CreateEntityCommandHandler  │  │   │  │ GetEntityByIdQueryHandler│  │
│  │  UpdateEntityCommandHandler  │  │   │  │ QueryEntitiesQueryHandler│  │
│  │  DeleteEntityCommandHandler  │  │   │  └──────────────────────────┘  │
│  └──────────────────────────────┘  │   │                                │
└──────────────────┬─────────────────┘   └────────────────┬───────────────┘
                   │                                      │
                   ▼                                      ▼
┌────────────────────────────────────┐   ┌────────────────────────────────┐
│         Write Database             │   │         Read Database          │
│     (EF Core - Full Model)         │   │    (Dapper/Raw - Optimized)    │
└────────────────────────────────────┘   └────────────────────────────────┘

Setup

Installation

bash
dotnet add package GrydCrud.Cqrs
dotnet add package MediatR

Registration

csharp
// Program.cs
using GrydCrud.Cqrs.Extensions;

builder.Services.AddMediatR(cfg => 
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

builder.Services.AddGrydCrudCqrs(options =>
{
    options.RegisterHandlersFromAssembly(typeof(Program).Assembly);
    options.EnablePipelineBehaviors = true;
});

Commands

CreateEntityCommand

csharp
using GrydCrud.Cqrs.Commands;
using MediatR;

// Command definition
public record CreateProductCommand(CreateProductDto Dto) 
    : IRequest<Result<ProductDto>>;

// Handler implementation
public class CreateProductCommandHandler 
    : IRequestHandler<CreateProductCommand, Result<ProductDto>>
{
    private readonly IRepository<Product> _repository;
    private readonly IMapper _mapper;
    private readonly IUnitOfWork _unitOfWork;
    
    public CreateProductCommandHandler(
        IRepository<Product> repository,
        IMapper mapper,
        IUnitOfWork unitOfWork)
    {
        _repository = repository;
        _mapper = mapper;
        _unitOfWork = unitOfWork;
    }
    
    public async Task<Result<ProductDto>> Handle(
        CreateProductCommand request,
        CancellationToken cancellationToken)
    {
        // Map DTO to entity
        var entity = _mapper.Map<Product>(request.Dto);
        
        // Add to repository
        await _repository.AddAsync(entity, cancellationToken);
        
        // Save changes
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        
        // Return result
        var resultDto = _mapper.Map<ProductDto>(entity);
        return Result<ProductDto>.Success(resultDto);
    }
}

UpdateEntityCommand

csharp
public record UpdateProductCommand(Guid Id, UpdateProductDto Dto) 
    : IRequest<Result<ProductDto>>;

public class UpdateProductCommandHandler 
    : IRequestHandler<UpdateProductCommand, Result<ProductDto>>
{
    private readonly IRepository<Product> _repository;
    private readonly IMapper _mapper;
    private readonly IUnitOfWork _unitOfWork;
    
    public UpdateProductCommandHandler(
        IRepository<Product> repository,
        IMapper mapper,
        IUnitOfWork unitOfWork)
    {
        _repository = repository;
        _mapper = mapper;
        _unitOfWork = unitOfWork;
    }
    
    public async Task<Result<ProductDto>> Handle(
        UpdateProductCommand request,
        CancellationToken cancellationToken)
    {
        // Find entity
        var entity = await _repository.GetByIdAsync(request.Id, cancellationToken);
        if (entity is null)
        {
            return Result<ProductDto>.Failure("NOT_FOUND", "Product not found");
        }
        
        // Update entity
        _mapper.Map(request.Dto, entity);
        
        // Save changes
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        
        // Return result
        var resultDto = _mapper.Map<ProductDto>(entity);
        return Result<ProductDto>.Success(resultDto);
    }
}

DeleteEntityCommand

csharp
public record DeleteProductCommand(Guid Id) : IRequest<Result>;

public class DeleteProductCommandHandler 
    : IRequestHandler<DeleteProductCommand, Result>
{
    private readonly IRepository<Product> _repository;
    private readonly IUnitOfWork _unitOfWork;
    
    public async Task<Result> Handle(
        DeleteProductCommand request,
        CancellationToken cancellationToken)
    {
        var entity = await _repository.GetByIdAsync(request.Id, cancellationToken);
        if (entity is null)
        {
            return Result.Failure("NOT_FOUND", "Product not found");
        }
        
        await _repository.DeleteAsync(entity, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        
        return Result.Success();
    }
}

Queries

GetByIdQuery

csharp
using GrydCrud.Cqrs.Queries;

public record GetProductByIdQuery(Guid Id) : IRequest<Result<ProductDto>>;

public class GetProductByIdQueryHandler 
    : IRequestHandler<GetProductByIdQuery, Result<ProductDto>>
{
    private readonly IReadOnlyRepository<Product> _repository;
    private readonly IMapper _mapper;
    
    public GetProductByIdQueryHandler(
        IReadOnlyRepository<Product> repository,
        IMapper mapper)
    {
        _repository = repository;
        _mapper = mapper;
    }
    
    public async Task<Result<ProductDto>> Handle(
        GetProductByIdQuery request,
        CancellationToken cancellationToken)
    {
        var entity = await _repository.GetByIdAsync(
            request.Id,
            cancellationToken,
            includes: q => q.Include(p => p.Category));
        
        if (entity is null)
        {
            return Result<ProductDto>.Failure("NOT_FOUND", "Product not found");
        }
        
        var dto = _mapper.Map<ProductDto>(entity);
        return Result<ProductDto>.Success(dto);
    }
}

QueryEntitiesQuery (Paginated)

csharp
public record QueryProductsQuery(QueryParameters Parameters) 
    : IRequest<Result<PagedResult<ProductDto>>>;

public class QueryProductsQueryHandler 
    : IRequestHandler<QueryProductsQuery, Result<PagedResult<ProductDto>>>
{
    private readonly IReadOnlyRepository<Product> _repository;
    private readonly IMapper _mapper;
    
    public async Task<Result<PagedResult<ProductDto>>> Handle(
        QueryProductsQuery request,
        CancellationToken cancellationToken)
    {
        var spec = new ProductsByFilterSpec(request.Parameters);
        
        var items = await _repository.ListAsync(spec, cancellationToken);
        var totalCount = await _repository.CountAsync(spec, cancellationToken);
        
        var dtos = _mapper.Map<List<ProductDto>>(items);
        
        var result = new PagedResult<ProductDto>(
            dtos,
            totalCount,
            request.Parameters.Page,
            request.Parameters.PageSize);
        
        return Result<PagedResult<ProductDto>>.Success(result);
    }
}

Generic CQRS Handlers

GrydCrud provides generic commands and queries for common operations:

csharp
// The following generic types are available in GrydCrud.Cqrs:

// Commands (in GrydCrud.Cqrs.Commands namespace):
// - CreateEntityCommand<TEntity, TCreateDto, TResultDto>
// - CreateEntityCommand<TEntity, TDto> (simplified - same DTO for input/output)
// - UpdateEntityCommand<TEntity, TUpdateDto, TResultDto>
// - DeleteEntityCommand<TEntity>

// Queries (in GrydCrud.Cqrs.Queries namespace):
// - GetEntityByIdQuery<TEntity, TResultDto>
// - GetEntitiesPagedQuery<TEntity, TResultDto, TQueryParameters>
// - GetEntitiesPagedQuery<TEntity, TResultDto> (simplified - uses QueryParameters)

Using Generic Commands

csharp
using GrydCrud.Cqrs.Commands;
using GrydCrud.Cqrs.Queries;

public class ProductsController : ControllerBase
{
    private readonly IMediator _mediator;
    
    public ProductsController(IMediator mediator)
    {
        _mediator = mediator;
    }
    
    [HttpPost]
    public async Task<IActionResult> Create(CreateProductDto dto)
    {
        // Use the generic command with Entity type
        var command = new CreateEntityCommand<Product, CreateProductDto, ProductDto>(dto);
        var result = await _mediator.Send(command);
        return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Errors);
    }
    
    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id)
    {
        var query = new GetEntityByIdQuery<Product, ProductDto>(id);
        var result = await _mediator.Send(query);
        return result.IsSuccess ? Ok(result.Value) : NotFound(result.Errors);
    }
    
    [HttpGet]
    public async Task<IActionResult> GetPaged([FromQuery] QueryParameters parameters)
    {
        var query = new GetEntitiesPagedQuery<Product, ProductDto>(parameters);
        var result = await _mediator.Send(query);
        return Ok(result.Value);
    }
}

Pipeline Behaviors

Validation Behavior

csharp
public class ValidationBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
    
    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }
    
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();
        
        var context = new ValidationContext<TRequest>(request);
        
        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));
        
        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();
        
        if (failures.Any())
            throw new ValidationException(failures);
        
        return await next();
    }
}

Logging Behavior

csharp
public class LoggingBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
    
    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }
    
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        
        _logger.LogInformation(
            "Handling {RequestName} with data: {@Request}",
            requestName,
            request);
        
        var stopwatch = Stopwatch.StartNew();
        
        try
        {
            var response = await next();
            
            stopwatch.Stop();
            _logger.LogInformation(
                "Handled {RequestName} in {ElapsedMilliseconds}ms",
                requestName,
                stopwatch.ElapsedMilliseconds);
            
            return response;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(
                ex,
                "Error handling {RequestName} after {ElapsedMilliseconds}ms",
                requestName,
                stopwatch.ElapsedMilliseconds);
            throw;
        }
    }
}

Transaction Behavior

csharp
public class TransactionBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IUnitOfWork _unitOfWork;
    
    public TransactionBehavior(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }
    
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Only wrap commands in transactions
        if (!IsCommand(typeof(TRequest)))
            return await next();
        
        await using var transaction = await _unitOfWork.BeginTransactionAsync(cancellationToken);
        
        try
        {
            var response = await next();
            await transaction.CommitAsync(cancellationToken);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }
    
    private static bool IsCommand(Type type) =>
        type.Name.EndsWith("Command");
}

Registering Behaviors

csharp
// Program.cs
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    
    // Add behaviors in order (first registered = outermost)
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
});

Domain Events

Publishing Events

csharp
public record ProductCreatedEvent(Guid ProductId, string ProductName) : INotification;

public class CreateProductCommandHandler 
    : IRequestHandler<CreateProductCommand, Result<ProductDto>>
{
    private readonly IMediator _mediator;
    // ... other dependencies
    
    public async Task<Result<ProductDto>> Handle(
        CreateProductCommand request,
        CancellationToken cancellationToken)
    {
        // Create entity
        var entity = _mapper.Map<Product>(request.Dto);
        await _repository.AddAsync(entity, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        
        // Publish event
        await _mediator.Publish(
            new ProductCreatedEvent(entity.Id, entity.Name),
            cancellationToken);
        
        return Result<ProductDto>.Success(_mapper.Map<ProductDto>(entity));
    }
}

Handling Events

csharp
public class ProductCreatedEventHandler : INotificationHandler<ProductCreatedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<ProductCreatedEventHandler> _logger;
    
    public ProductCreatedEventHandler(
        IEmailService emailService,
        ILogger<ProductCreatedEventHandler> logger)
    {
        _emailService = emailService;
        _logger = logger;
    }
    
    public async Task Handle(
        ProductCreatedEvent notification,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Product created: {ProductId} - {ProductName}",
            notification.ProductId,
            notification.ProductName);
        
        await _emailService.SendProductCreatedNotificationAsync(
            notification.ProductId,
            notification.ProductName,
            cancellationToken);
    }
}

Read/Write Separation

Separate Read Models

csharp
// Write model (EF Core)
public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public Guid CategoryId { get; set; }
    public Category Category { get; set; }
    public List<ProductImage> Images { get; set; }
    public List<Review> Reviews { get; set; }
    // ... many more properties
}

// Read model (optimized for queries)
public class ProductReadModel
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string CategoryName { get; set; }
    public double AverageRating { get; set; }
    public int ReviewCount { get; set; }
    public string ThumbnailUrl { get; set; }
}

Using Dapper for Reads

csharp
public class QueryProductsQueryHandler 
    : IRequestHandler<QueryProductsQuery, Result<PagedResult<ProductReadModel>>>
{
    private readonly IDbConnectionFactory _connectionFactory;
    
    public async Task<Result<PagedResult<ProductReadModel>>> Handle(
        QueryProductsQuery request,
        CancellationToken cancellationToken)
    {
        using var connection = await _connectionFactory.CreateConnectionAsync();
        
        var sql = @"
            SELECT 
                p.Id,
                p.Name,
                p.Price,
                c.Name as CategoryName,
                COALESCE(AVG(r.Rating), 0) as AverageRating,
                COUNT(r.Id) as ReviewCount,
                (SELECT TOP 1 Url FROM ProductImages WHERE ProductId = p.Id ORDER BY [Order]) as ThumbnailUrl
            FROM Products p
            LEFT JOIN Categories c ON p.CategoryId = c.Id
            LEFT JOIN Reviews r ON p.Id = r.ProductId
            WHERE (@Search IS NULL OR p.Name LIKE '%' + @Search + '%')
              AND (@CategoryId IS NULL OR p.CategoryId = @CategoryId)
              AND (@MinPrice IS NULL OR p.Price >= @MinPrice)
              AND (@MaxPrice IS NULL OR p.Price <= @MaxPrice)
            GROUP BY p.Id, p.Name, p.Price, c.Name
            ORDER BY p.Name
            OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
            
            SELECT COUNT(DISTINCT p.Id)
            FROM Products p
            WHERE (@Search IS NULL OR p.Name LIKE '%' + @Search + '%')
              AND (@CategoryId IS NULL OR p.CategoryId = @CategoryId)
              AND (@MinPrice IS NULL OR p.Price >= @MinPrice)
              AND (@MaxPrice IS NULL OR p.Price <= @MaxPrice);
        ";
        
        using var multi = await connection.QueryMultipleAsync(sql, new
        {
            request.Parameters.Search,
            request.Parameters.CategoryId,
            request.Parameters.MinPrice,
            request.Parameters.MaxPrice,
            Offset = (request.Parameters.Page - 1) * request.Parameters.PageSize,
            request.Parameters.PageSize
        });
        
        var items = (await multi.ReadAsync<ProductReadModel>()).ToList();
        var totalCount = await multi.ReadSingleAsync<int>();
        
        return Result<PagedResult<ProductReadModel>>.Success(
            new PagedResult<ProductReadModel>(
                items,
                totalCount,
                request.Parameters.Page,
                request.Parameters.PageSize));
    }
}

Controller Integration

Full CQRS Controller

csharp
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IMediator _mediator;
    
    public ProductsController(IMediator mediator)
    {
        _mediator = mediator;
    }
    
    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(
        [FromBody] CreateProductDto dto,
        CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(new CreateProductCommand(dto), cancellationToken);
        
        return result.IsSuccess
            ? CreatedAtAction(nameof(GetById), new { id = result.Value.Id }, result.Value)
            : BadRequest(result.Error);
    }
    
    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(new GetProductByIdQuery(id), cancellationToken);
        
        return result.IsSuccess
            ? Ok(result.Value)
            : NotFound(result.Error);
    }
    
    [HttpGet]
    [ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
    public async Task<IActionResult> Query(
        [FromQuery] QueryParameters parameters,
        CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(new QueryProductsQuery(parameters), cancellationToken);
        return Ok(result.Value);
    }
    
    [HttpPut("{id:guid}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Update(
        Guid id,
        [FromBody] UpdateProductDto dto,
        CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(new UpdateProductCommand(id, dto), cancellationToken);
        
        return result.IsSuccess
            ? Ok(result.Value)
            : NotFound(result.Error);
    }
    
    [HttpDelete("{id:guid}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Delete(
        Guid id,
        CancellationToken cancellationToken)
    {
        var result = await _mediator.Send(new DeleteProductCommand(id), cancellationToken);
        
        return result.IsSuccess
            ? NoContent()
            : NotFound(result.Error);
    }
}

Best Practices

  1. Keep handlers focused - One handler per command/query
  2. Use behaviors for cross-cutting concerns - Logging, validation, transactions
  3. Separate read and write models - Optimize for each use case
  4. Use domain events - Decouple side effects from main operations
  5. Don't query in command handlers - Keep reads and writes separate
  6. Use specifications - Encapsulate query logic for reuse
  7. Test handlers in isolation - Mock dependencies for unit tests

Released under the MIT License.