22 KiB
22 KiB
CQRS-MediatR - Detailed Reference
Detailed code examples for CQRS pattern với MediatR trong ASP.NET Core.
Table of Contents
- Project Setup
- Command Patterns
- Query Patterns
- Pipeline Behaviors
- Idempotency
- Domain Events
- 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);
}
}