Chapter 04
CQRS Pattern
với MediatR
Tách Command & Query — kiến trúc rõ ràng, dễ scale
CQRS là gì?
Command Query Responsibility Segregation — nguyên tắc tách biệt hoàn toàn thao tác đọc (Query) và thao tác ghi (Command). Mỗi loại có model, handler, và thậm chí có thể dùng database riêng.
❌
CRUD truyền thống
Một model cho cả đọc và ghi
- Cùng một
ProductServicexử lý mọi thứ - DTO phình to, chứa cả field đọc lẫn ghi
- Tối ưu đọc ảnh hưởng đến ghi và ngược lại
- Validation phức tạp khi một model dùng chung
- Khó scale riêng read/write
✔
CQRS
Tách riêng model đọc và ghi
- Command handler xử lý ghi, Query handler xử lý đọc
- DTO tối ưu cho từng use case cụ thể
- Tối ưu read/write độc lập
- Validation rõ ràng per command
- Có thể scale read replica riêng
CQRS Flow với MediatR
Controller
HTTP Request
→
Command
ghi dữ liệu
→
Handler
write DB
Controller
HTTP Request
→
Query
đọc dữ liệu
→
Handler
read DB
Marker Interfaces
Tạo các interface đánh dấu để phân biệt Command và Query — giúp apply behaviors có chọn lọc:
ICommand
: IRequest
Command void (không trả kết quả)
ICommand<T>
: IRequest<T>
Command trả về kết quả (vd: ID mới tạo)
IQuery<T>
: IRequest<T>
Query luôn trả về kết quả
C#
// Marker interfaces public interface ICommand : IRequest { } public interface ICommand<TResponse> : IRequest<TResponse> { } public interface IQuery<TResponse> : IRequest<TResponse> { } // Marker handler interfaces (optional) public interface ICommandHandler<TCommand> : IRequestHandler<TCommand> where TCommand : ICommand { } public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, TResponse> where TCommand : ICommand<TResponse> { } public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, TResponse> where TQuery : IQuery<TResponse> { }
Folder Structure
Hai cách tổ chức phổ biến. Khuyến nghị Vertical Slice cho dự án dùng MediatR:
Vertical Slice (Feature-based)
src/Application/
Features/
Products/
Commands/
CreateProduct.cs ← Command + Handler + Validator cùng file
UpdateProduct.cs
DeleteProduct.cs
Queries/
GetProductById.cs ← Query + Handler + DTO cùng file
GetProductsList.cs
Orders/
Commands/
Queries/
Common/
Behaviors/ ← Shared pipeline behaviors
Interfaces/ ← ICommand, IQuery, etc.
Features/
Products/
Commands/
CreateProduct.cs ← Command + Handler + Validator cùng file
UpdateProduct.cs
DeleteProduct.cs
Queries/
GetProductById.cs ← Query + Handler + DTO cùng file
GetProductsList.cs
Orders/
Commands/
Queries/
Common/
Behaviors/ ← Shared pipeline behaviors
Interfaces/ ← ICommand, IQuery, etc.
Đặt Command + Handler + Validator trong cùng một file. Khi mở file, bạn thấy toàn bộ use case.
Đây là ưu điểm lớn nhất của Vertical Slice — co-location.
Command Examples
CreateProduct — Command + Handler + Validator cùng file
C#
// Features/Products/Commands/CreateProduct.cs public record CreateProductCommand( string Name, decimal Price, string Category ) : ICommand<int>; public class CreateProductValidator : AbstractValidator<CreateProductCommand> { public CreateProductValidator() { RuleFor(x => x.Name) .NotEmpty().MaximumLength(200); RuleFor(x => x.Price) .GreaterThan(0); RuleFor(x => x.Category) .NotEmpty(); } } public class CreateProductHandler : ICommandHandler<CreateProductCommand, int> { private readonly AppDbContext _db; public CreateProductHandler(AppDbContext db) => _db = db; public async Task<int> Handle( CreateProductCommand request, CancellationToken ct) { var product = new Product { Name = request.Name, Price = request.Price, Category = request.Category }; _db.Products.Add(product); await _db.SaveChangesAsync(ct); return product.Id; } }
UpdateProduct
C#
public record UpdateProductCommand( int Id, string Name, decimal Price ) : ICommand; public class UpdateProductHandler : ICommandHandler<UpdateProductCommand> { private readonly AppDbContext _db; public UpdateProductHandler(AppDbContext db) => _db = db; public async Task Handle( UpdateProductCommand request, CancellationToken ct) { var product = await _db.Products .FindAsync(new object[] { request.Id }, ct) ?? throw new NotFoundException("Product", request.Id); product.Name = request.Name; product.Price = request.Price; await _db.SaveChangesAsync(ct); } }
Query Examples
GetProductById
C#
// Features/Products/Queries/GetProductById.cs public record ProductDto( int Id, string Name, decimal Price, string Category); public record GetProductByIdQuery(int Id) : IQuery<ProductDto>; public class GetProductByIdHandler : IQueryHandler<GetProductByIdQuery, ProductDto> { private readonly AppDbContext _db; public GetProductByIdHandler(AppDbContext db) => _db = db; public async Task<ProductDto> Handle( GetProductByIdQuery request, CancellationToken ct) { var p = await _db.Products .AsNoTracking() // Query = read-only, không cần tracking .FirstOrDefaultAsync(x => x.Id == request.Id, ct) ?? throw new NotFoundException("Product", request.Id); return new ProductDto(p.Id, p.Name, p.Price, p.Category); } }
GetProductsList (with Pagination)
C#
public record GetProductsListQuery( string? Category, int Page = 1, int PageSize = 20 ) : IQuery<PagedResult<ProductDto>>; public record PagedResult<T>( List<T> Items, int TotalCount, int Page, int PageSize) { public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); public bool HasNext => Page < TotalPages; public bool HasPrev => Page > 1; } public class GetProductsListHandler : IQueryHandler<GetProductsListQuery, PagedResult<ProductDto>> { private readonly AppDbContext _db; public GetProductsListHandler(AppDbContext db) => _db = db; public async Task<PagedResult<ProductDto>> Handle( GetProductsListQuery request, CancellationToken ct) { var query = _db.Products.AsNoTracking(); if (!string.IsNullOrEmpty(request.Category)) query = query.Where(p => p.Category == request.Category); var total = await query.CountAsync(ct); var items = await query .OrderBy(p => p.Name) .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category)) .ToListAsync(ct); return new PagedResult<ProductDto>( items, total, request.Page, request.PageSize); } }
Type-Constrained Behaviors
Dùng generic constraints với marker interfaces để behaviors chỉ apply cho Command hoặc Query:
C#
// Behavior CHỈ apply cho Commands (ghi) public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : ICommand<TResponse> // ← Constraint! { private readonly AppDbContext _db; public TransactionBehavior(AppDbContext db) => _db = db; public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct) { await using var transaction = await _db.Database.BeginTransactionAsync(ct); try { var response = await next(); await transaction.CommitAsync(ct); return response; } catch { await transaction.RollbackAsync(ct); throw; } } } // Behavior CHỈ apply cho Queries (đọc) public class CachingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : IQuery<TResponse> // ← Chỉ queries! { private readonly IDistributedCache _cache; public CachingBehavior(IDistributedCache cache) => _cache = cache; public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct) { var cacheKey = $"{typeof(TRequest).Name}:{ JsonSerializer.Serialize(request)}"; var cached = await _cache.GetStringAsync(cacheKey, ct); if (cached != null) return JsonSerializer.Deserialize<TResponse>(cached)!; var response = await next(); await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(response), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct); return response; } }
TransactionBehavior chỉ bọc commands — queries không cần transaction vì chỉ đọc.
CachingBehavior chỉ bọc queries — caching commands không có ý nghĩa. Đây chính là sức mạnh
của type constraints + marker interfaces.