Files
pos-system/microservices/.agent/skills/cqrs-mediatr/references/REFERENCE.md
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

22 KiB

CQRS-MediatR - Detailed Reference

Detailed code examples for CQRS pattern với MediatR trong ASP.NET Core.

Table of Contents

  1. Project Setup
  2. Command Patterns
  3. Query Patterns
  4. Pipeline Behaviors
  5. Idempotency
  6. Domain Events
  7. Transaction Management

Project Setup

Package Dependencies

<ItemGroup>
  <PackageReference Include="MediatR" Version="12.2.0" />
  <PackageReference Include="FluentValidation" Version="11.9.0" />
  <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
  <PackageReference Include="Dapper" Version="2.1.24" />
</ItemGroup>

DI Registration

/// <summary>
/// EN: Configure MediatR with all behaviors.
/// VI: Cấu hình MediatR với tất cả behaviors.
/// </summary>

// EN: Register MediatR / VI: Đăng ký MediatR
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    
    // EN: Pipeline behaviors (order matters!)
    // VI: Pipeline behaviors (thứ tự quan trọng!)
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
});

// EN: Register FluentValidation validators
// VI: Đăng ký FluentValidation validators
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

// EN: Register Dapper connection for queries
// VI: Đăng ký Dapper connection cho queries
builder.Services.AddScoped<IDbConnection>(_ =>
    new NpgsqlConnection(builder.Configuration.GetConnectionString("DefaultConnection")));

Command Patterns

Immutable Command Records

/// <summary>
/// EN: Command to create an order. Commands should be immutable.
/// VI: Command tạo order. Commands nên là immutable.
/// </summary>
public record CreateOrderCommand : IRequest<OrderResult>
{
    public string UserId { get; init; }
    public Address ShippingAddress { get; init; }
    public IReadOnlyList<OrderItemDto> Items { get; init; }

    public CreateOrderCommand(
        string userId,
        Address shippingAddress,
        IReadOnlyList<OrderItemDto> items)
    {
        UserId = userId;
        ShippingAddress = shippingAddress;
        Items = items;
    }
}

/// <summary>
/// EN: Command to update order status.
/// VI: Command cập nhật trạng thái order.
/// </summary>
public record UpdateOrderStatusCommand(
    Guid OrderId,
    string UserId,
    OrderStatus NewStatus) : IRequest<OrderResult>;

/// <summary>
/// EN: Command to cancel order.
/// VI: Command hủy order.
/// </summary>
public record CancelOrderCommand(
    Guid OrderId,
    string UserId,
    string Reason) : IRequest<OrderResult>;

Command Handler with Full Domain Logic

/// <summary>
/// EN: Handler for CreateOrderCommand with domain validation.
/// VI: Handler cho CreateOrderCommand với domain validation.
/// </summary>
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IProductService _productService;
    private readonly ILogger<CreateOrderCommandHandler> _logger;

    public CreateOrderCommandHandler(
        IOrderRepository orderRepository,
        IProductService productService,
        ILogger<CreateOrderCommandHandler> logger)
    {
        _orderRepository = orderRepository;
        _productService = productService;
        _logger = logger;
    }

    public async Task<OrderResult> Handle(
        CreateOrderCommand request,
        CancellationToken ct)
    {
        // EN: Validate products exist and get prices
        // VI: Xác thực products tồn tại và lấy giá
        var productIds = request.Items.Select(i => i.ProductId).ToList();
        var products = await _productService.GetByIdsAsync(productIds, ct);

        if (products.Count != productIds.Count)
        {
            var missingIds = productIds.Except(products.Select(p => p.Id));
            throw new NotFoundException("Products", string.Join(", ", missingIds));
        }

        // EN: Create order aggregate
        // VI: Tạo order aggregate
        var order = new Order(request.UserId, request.ShippingAddress);

        foreach (var item in request.Items)
        {
            var product = products.First(p => p.Id == item.ProductId);
            order.AddItem(product.Id, item.Quantity, product.Price);
        }

        // EN: Persist via repository
        // VI: Lưu qua repository
        await _orderRepository.AddAsync(order, ct);
        await _orderRepository.UnitOfWork.SaveChangesAsync(ct);

        _logger.LogInformation(
            "EN: Order {OrderId} created for user {UserId} / " +
            "VI: Order {OrderId} đã tạo cho user {UserId}",
            order.Id, request.UserId);

        return new OrderResult(order.Id, order.Status.ToString(), order.TotalAmount);
    }
}

Command Validator

/// <summary>
/// EN: FluentValidation validator for CreateOrderCommand.
/// VI: FluentValidation validator cho CreateOrderCommand.
/// </summary>
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.UserId)
            .NotEmpty().WithMessage("User ID is required");

        RuleFor(x => x.ShippingAddress)
            .NotNull().WithMessage("Shipping address is required")
            .SetValidator(new AddressValidator()!);

        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("At least one item is required")
            .Must(items => items.Count <= 100)
            .WithMessage("Maximum 100 items per order");

        RuleForEach(x => x.Items)
            .SetValidator(new OrderItemDtoValidator());
    }
}

public class AddressValidator : AbstractValidator<Address>
{
    public AddressValidator()
    {
        RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
        RuleFor(x => x.City).NotEmpty().MaximumLength(100);
        RuleFor(x => x.PostalCode).NotEmpty().Matches(@"^\d{5}(-\d{4})?$");
        RuleFor(x => x.Country).NotEmpty().MaximumLength(100);
    }
}

public class OrderItemDtoValidator : AbstractValidator<OrderItemDto>
{
    public OrderItemDtoValidator()
    {
        RuleFor(x => x.ProductId).NotEmpty();
        RuleFor(x => x.Quantity).GreaterThan(0).LessThanOrEqualTo(1000);
        RuleFor(x => x.UnitPrice).GreaterThanOrEqualTo(0);
    }
}

Query Patterns

Optimized Query with Dapper

/// <summary>
/// EN: Query for paginated user orders.
/// VI: Query cho orders phân trang của user.
/// </summary>
public record GetUserOrdersQuery(
    string UserId,
    int Skip = 0,
    int Take = 20,
    OrderStatus? StatusFilter = null) : IRequest<PagedResult<OrderSummaryDto>>;

/// <summary>
/// EN: Handler using Dapper for optimized reads.
/// VI: Handler dùng Dapper cho đọc tối ưu.
/// </summary>
public class GetUserOrdersQueryHandler 
    : IRequestHandler<GetUserOrdersQuery, PagedResult<OrderSummaryDto>>
{
    private readonly IDbConnection _connection;

    public GetUserOrdersQueryHandler(IDbConnection connection)
    {
        _connection = connection;
    }

    public async Task<PagedResult<OrderSummaryDto>> Handle(
        GetUserOrdersQuery request,
        CancellationToken ct)
    {
        var whereClause = "WHERE o.UserId = @UserId";
        var parameters = new DynamicParameters();
        parameters.Add("UserId", request.UserId);
        parameters.Add("Skip", request.Skip);
        parameters.Add("Take", request.Take);

        if (request.StatusFilter.HasValue)
        {
            whereClause += " AND o.Status = @Status";
            parameters.Add("Status", request.StatusFilter.Value.ToString());
        }

        // EN: Count total
        // VI: Đếm tổng
        var countSql = $"SELECT COUNT(*) FROM Orders o {whereClause}";
        var total = await _connection.ExecuteScalarAsync<int>(countSql, parameters);

        // EN: Get data with pagination
        // VI: Lấy dữ liệu với phân trang
        var dataSql = $@"
            SELECT 
                o.Id,
                o.Status,
                o.TotalAmount,
                o.CreatedAt,
                (SELECT COUNT(*) FROM OrderItems WHERE OrderId = o.Id) as ItemCount
            FROM Orders o
            {whereClause}
            ORDER BY o.CreatedAt DESC
            OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY";

        var orders = await _connection.QueryAsync<OrderSummaryDto>(dataSql, parameters);

        return new PagedResult<OrderSummaryDto>(
            orders.ToList(),
            total,
            request.Skip / request.Take + 1,
            request.Take);
    }
}

Complex Query with Multi-Mapping

/// <summary>
/// EN: Query for order detail with items.
/// VI: Query cho chi tiết order với items.
/// </summary>
public record GetOrderDetailQuery(
    Guid OrderId,
    string UserId) : IRequest<OrderDetailDto?>;

public class GetOrderDetailQueryHandler
    : IRequestHandler<GetOrderDetailQuery, OrderDetailDto?>
{
    private readonly IDbConnection _connection;

    public async Task<OrderDetailDto?> Handle(
        GetOrderDetailQuery request,
        CancellationToken ct)
    {
        const string sql = @"
            SELECT 
                o.Id, o.UserId, o.Status, o.TotalAmount, o.CreatedAt,
                o.ShippingAddress_Street as Street,
                o.ShippingAddress_City as City,
                o.ShippingAddress_PostalCode as PostalCode,
                o.ShippingAddress_Country as Country,
                oi.Id as ItemId, oi.ProductId, oi.Quantity, oi.UnitPrice,
                p.Name as ProductName
            FROM Orders o
            LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
            LEFT JOIN Products p ON oi.ProductId = p.Id
            WHERE o.Id = @OrderId AND o.UserId = @UserId";

        var orderDict = new Dictionary<Guid, OrderDetailDto>();

        await _connection.QueryAsync<OrderDetailDto, OrderItemDetailDto?, OrderDetailDto>(
            sql,
            (order, item) =>
            {
                if (!orderDict.TryGetValue(order.Id, out var existingOrder))
                {
                    existingOrder = order;
                    existingOrder.Items = new List<OrderItemDetailDto>();
                    orderDict.Add(order.Id, existingOrder);
                }

                if (item != null)
                    existingOrder.Items.Add(item);

                return existingOrder;
            },
            new { request.OrderId, request.UserId },
            splitOn: "ItemId");

        return orderDict.Values.FirstOrDefault();
    }
}

Pipeline Behaviors

Complete Validation Behavior

/// <summary>
/// EN: Validation behavior with detailed error mapping.
/// VI: Validation behavior với ánh xạ lỗi chi tiết.
/// </summary>
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
    private readonly ILogger<ValidationBehavior<TRequest, TResponse>> _logger;

    public ValidationBehavior(
        IEnumerable<IValidator<TRequest>> validators,
        ILogger<ValidationBehavior<TRequest, TResponse>> logger)
    {
        _validators = validators;
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var typeName = typeof(TRequest).Name;

        if (!_validators.Any())
        {
            _logger.LogDebug("No validators for {RequestType}", typeName);
            return await next();
        }

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, ct)));

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
        {
            _logger.LogWarning(
                "Validation failed for {RequestType}: {Errors}",
                typeName,
                string.Join(", ", failures.Select(f => f.ErrorMessage)));

            throw new ValidationException(failures);
        }

        return await next();
    }
}

Logging Behavior with Performance Tracking

/// <summary>
/// EN: Logging behavior with performance alerts.
/// VI: Logging behavior với cảnh báo hiệu năng.
/// </summary>
public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
    private readonly TimeSpan _slowThreshold = TimeSpan.FromSeconds(3);

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        var requestId = Guid.NewGuid().ToString("N")[..8];

        _logger.LogInformation(
            "[{RequestId}] Handling {RequestName}",
            requestId, requestName);

        var stopwatch = Stopwatch.StartNew();

        try
        {
            var response = await next();
            stopwatch.Stop();

            if (stopwatch.Elapsed > _slowThreshold)
            {
                _logger.LogWarning(
                    "[{RequestId}] SLOW: {RequestName} took {ElapsedMs}ms",
                    requestId, requestName, stopwatch.ElapsedMilliseconds);
            }
            else
            {
                _logger.LogInformation(
                    "[{RequestId}] Completed {RequestName} in {ElapsedMs}ms",
                    requestId, requestName, stopwatch.ElapsedMilliseconds);
            }

            return response;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(ex,
                "[{RequestId}] FAILED: {RequestName} after {ElapsedMs}ms",
                requestId, requestName, stopwatch.ElapsedMilliseconds);
            throw;
        }
    }
}

Transaction Behavior

/// <summary>
/// EN: Transaction behavior for commands.
/// VI: Transaction behavior cho commands.
/// </summary>
public class TransactionBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ApplicationDbContext _dbContext;
    private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;

    public TransactionBehavior(
        ApplicationDbContext dbContext,
        ILogger<TransactionBehavior<TRequest, TResponse>> logger)
    {
        _dbContext = dbContext;
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        // EN: Only wrap commands in transactions
        // VI: Chỉ wrap commands trong transactions
        if (!IsCommand())
            return await next();

        var typeName = typeof(TRequest).Name;

        await using var transaction = await _dbContext.Database
            .BeginTransactionAsync(ct);

        try
        {
            _logger.LogDebug("Begin transaction for {RequestType}", typeName);

            var response = await next();

            await transaction.CommitAsync(ct);

            _logger.LogDebug("Committed transaction for {RequestType}", typeName);

            return response;
        }
        catch (Exception ex)
        {
            await transaction.RollbackAsync(ct);
            _logger.LogError(ex, "Rolled back transaction for {RequestType}", typeName);
            throw;
        }
    }

    private static bool IsCommand()
    {
        return typeof(TRequest).Name.EndsWith("Command");
    }
}

Idempotency

Identified Command Pattern

/// <summary>
/// EN: Wrapper for idempotent commands.
/// VI: Wrapper cho commands idempotent.
/// </summary>
public record IdentifiedCommand<TCommand, TResult>(
    Guid RequestId,
    TCommand Command) : IRequest<TResult>
    where TCommand : IRequest<TResult>;

/// <summary>
/// EN: Handler for identified commands.
/// VI: Handler cho identified commands.
/// </summary>
public class IdentifiedCommandHandler<TCommand, TResult>
    : IRequestHandler<IdentifiedCommand<TCommand, TResult>, TResult>
    where TCommand : IRequest<TResult>
{
    private readonly IMediator _mediator;
    private readonly IRequestManager _requestManager;
    private readonly ILogger<IdentifiedCommandHandler<TCommand, TResult>> _logger;

    public IdentifiedCommandHandler(
        IMediator mediator,
        IRequestManager requestManager,
        ILogger<IdentifiedCommandHandler<TCommand, TResult>> logger)
    {
        _mediator = mediator;
        _requestManager = requestManager;
        _logger = logger;
    }

    public async Task<TResult> Handle(
        IdentifiedCommand<TCommand, TResult> request,
        CancellationToken ct)
    {
        var commandName = typeof(TCommand).Name;

        // EN: Check if already processed
        // VI: Kiểm tra đã xử lý chưa
        if (await _requestManager.ExistsAsync(request.RequestId, ct))
        {
            _logger.LogWarning(
                "Duplicate request {RequestId} for {CommandName}",
                request.RequestId, commandName);

            return default!; // EN: Return cached result if needed
        }

        // EN: Mark as processing
        // VI: Đánh dấu đang xử lý
        await _requestManager.CreateAsync(request.RequestId, commandName, ct);

        try
        {
            var result = await _mediator.Send(request.Command, ct);

            _logger.LogInformation(
                "Processed {CommandName} with RequestId {RequestId}",
                commandName, request.RequestId);

            return result;
        }
        catch
        {
            await _requestManager.DeleteAsync(request.RequestId, ct);
            throw;
        }
    }
}

Request Manager

/// <summary>
/// EN: Interface for request deduplication.
/// VI: Interface cho request deduplication.
/// </summary>
public interface IRequestManager
{
    Task<bool> ExistsAsync(Guid requestId, CancellationToken ct);
    Task CreateAsync(Guid requestId, string commandName, CancellationToken ct);
    Task DeleteAsync(Guid requestId, CancellationToken ct);
}

/// <summary>
/// EN: Database-backed request manager.
/// VI: Request manager với database.
/// </summary>
public class RequestManager : IRequestManager
{
    private readonly ApplicationDbContext _context;

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

    public async Task<bool> ExistsAsync(Guid requestId, CancellationToken ct)
    {
        return await _context.ClientRequests
            .AnyAsync(r => r.Id == requestId, ct);
    }

    public async Task CreateAsync(Guid requestId, string commandName, CancellationToken ct)
    {
        var request = new ClientRequest
        {
            Id = requestId,
            Name = commandName,
            Time = DateTime.UtcNow
        };

        _context.ClientRequests.Add(request);
        await _context.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(Guid requestId, CancellationToken ct)
    {
        var request = await _context.ClientRequests.FindAsync(new object[] { requestId }, ct);
        if (request != null)
        {
            _context.ClientRequests.Remove(request);
            await _context.SaveChangesAsync(ct);
        }
    }
}

/// <summary>
/// EN: Entity for tracking processed requests.
/// VI: Entity theo dõi requests đã xử lý.
/// </summary>
public class ClientRequest
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public DateTime Time { get; set; }
}

Domain Events

Publishing Domain Events

/// <summary>
/// EN: Domain event interface.
/// VI: Interface domain event.
/// </summary>
public interface IDomainEvent : INotification
{
    DateTime OccurredOn { get; }
}

/// <summary>
/// EN: Order created domain event.
/// VI: Domain event order được tạo.
/// </summary>
public record OrderCreatedEvent(
    Guid OrderId,
    string UserId,
    decimal TotalAmount) : IDomainEvent
{
    public DateTime OccurredOn { get; } = DateTime.UtcNow;
}

/// <summary>
/// EN: Handler for OrderCreatedEvent.
/// VI: Handler cho OrderCreatedEvent.
/// </summary>
public class OrderCreatedEventHandler : INotificationHandler<OrderCreatedEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<OrderCreatedEventHandler> _logger;

    public OrderCreatedEventHandler(
        IEmailService emailService,
        ILogger<OrderCreatedEventHandler> logger)
    {
        _emailService = emailService;
        _logger = logger;
    }

    public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
    {
        _logger.LogInformation(
            "Sending order confirmation email for Order {OrderId}",
            notification.OrderId);

        await _emailService.SendOrderConfirmationAsync(
            notification.UserId,
            notification.OrderId,
            notification.TotalAmount,
            ct);
    }
}

Resources / Tài Nguyên