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 MediatRRegistration
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
- Keep handlers focused - One handler per command/query
- Use behaviors for cross-cutting concerns - Logging, validation, transactions
- Separate read and write models - Optimize for each use case
- Use domain events - Decouple side effects from main operations
- Don't query in command handlers - Keep reads and writes separate
- Use specifications - Encapsulate query logic for reuse
- Test handlers in isolation - Mock dependencies for unit tests