Bài blog
CQRS và Saga Pattern trong .NET: Xây Dựng Workflow Phân Tán Đáng Tin Cậy
CQRS và Saga giải quyết hai vấn đề liên quan: CQRS tách read/write model bên trong service boundary, còn Saga điều phối workflow dài hạn giữa nhiều service.
- Danh mục
- architecture
- Xuất bản
Project
GitHub placeholder: cqrs-saga-dotnet
Project dự kiến mô phỏng workflow đặt hàng:
OrderServicenhận command và sở hữu order aggregate.InventoryServicereserve và release stock.PaymentServicecharge và refund payment.ShippingServicetạo shipment.SagaOrchestratorđiều phối workflow giữa các service.- Outbox table đảm bảo state change đã commit thì integration event sẽ được publish về sau.
Vì Sao Cần CQRS và Saga
Một hệ thống thường bắt đầu với một model dùng cho mọi thứ: tạo order, cập nhật status, render danh sách, filter dashboard, và xuất báo cáo. Khi hệ thống lớn lên, write side và read side bắt đầu có lý do thay đổi khác nhau.
Write side cần bảo vệ business invariant: order không được rỗng, payment chỉ được charge sau khi inventory đã reserve, shipped order không thể cancel. Read side lại cần nhanh, phẳng, dễ filter, và phù hợp với UI.
CQRS tách hai nhu cầu này. Saga giải quyết vấn đề khác: một business operation trải qua nhiều service và không thể dùng một database transaction duy nhất.
CQRS Trong Một Câu
Command Query Responsibility Segregation tách operation làm thay đổi state khỏi operation chỉ đọc state.
- Command: thể hiện ý định thay đổi hệ thống, ví dụ
PlaceOrder,CancelOrder,ApproveRefund. - Query: lấy dữ liệu mà không thay đổi state, ví dụ
GetOrderDetails,SearchOrders,GetCustomerOrderHistory.
CQRS không bắt buộc phải có event sourcing, Kafka, microservices, hay nhiều database. Ở mức đơn giản nhất, nó là cách tổ chức code trong một .NET application.
Shape CQRS Thực Tế Với .NET
MediatR thường được dùng để route command/query đến handler.
public sealed record PlaceOrderCommand(
Guid CustomerId,
IReadOnlyList<OrderLineRequest> Lines
) : IRequest<Guid>;
Command handler chịu trách nhiệm write behavior:
public sealed class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>
{
private readonly OrdersDbContext _db;
public PlaceOrderHandler(OrdersDbContext db)
{
_db = db;
}
public async Task<Guid> Handle(PlaceOrderCommand command, CancellationToken cancellationToken)
{
var order = Order.Place(command.CustomerId, command.Lines);
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
return order.Id;
}
}
Query handler nên đơn giản và tối ưu cho read:
public sealed record GetOrderDetailsQuery(Guid OrderId) : IRequest<OrderDetailsDto?>;
public sealed class GetOrderDetailsHandler
: IRequestHandler<GetOrderDetailsQuery, OrderDetailsDto?>
{
private readonly OrdersDbContext _db;
public Task<OrderDetailsDto?> Handle(GetOrderDetailsQuery query, CancellationToken ct)
{
return _db.Orders
.AsNoTracking()
.Where(order => order.Id == query.OrderId)
.Select(order => new OrderDetailsDto(order.Id, order.Status, order.Total))
.SingleOrDefaultAsync(ct);
}
}
Điểm quan trọng không phải là MediatR. Điểm quan trọng là write logic và read logic có thể tiến hóa độc lập.
Write Model Bảo Vệ Invariant
Command không nên chỉ set property. Nó nên gọi behavior trên aggregate.
public void MarkPaid()
{
if (Status != OrderStatus.PendingPayment)
{
throw new DomainException("Payment can only be accepted after inventory is reserved.");
}
Status = OrderStatus.ReadyToShip;
}
Read model có thể flat và denormalized, còn write model giữ domain rule chặt chẽ. Đây là giá trị thực tế của CQRS.
Domain Event, Integration Event, và Outbox
Domain event là event nội bộ trong bounded context. Integration event là contract giữa các service.
Không nên publish trực tiếp domain object ra ngoài. Integration event cần ổn định, version được, và chỉ chứa dữ liệu consumer cần.
Outbox pattern giải quyết bug kinh điển:
- Save order vào database.
- Publish
OrderPlacedlên broker. - Service crash giữa hai bước.
Kết quả là database có order nhưng service khác không biết. Outbox lưu outgoing message trong cùng transaction với business change.
public sealed class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; } = "";
public string Payload { get; set; } = "";
public DateTimeOffset OccurredAt { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
}
Background worker đọc message chưa publish và gửi lên broker. Điều này cho at-least-once delivery, vì vậy consumer phải idempotent.
Saga Bắt Đầu Ở Đâu
CQRS tổ chức logic bên trong service. Saga điều phối nhiều service.
Checkout có thể gồm:
Place order
-> reserve inventory
-> charge payment
-> create shipment
-> confirm order
Nếu payment fail, inventory phải được release. Nếu shipment fail, payment có thể cần refund. Vì mỗi service sở hữu database riêng, không thể dùng một distributed transaction đơn giản.
Saga là chuỗi local transaction kèm compensating action.
OrderPlaced
-> ReserveInventory
-> InventoryReserved
-> ChargePayment
-> PaymentFailed
-> ReleaseInventory
-> OrderCancelled
Choreography Saga
Trong choreography, mỗi service lắng nghe event và publish event kế tiếp.
OrderService publishes OrderPlaced
InventoryService consumes OrderPlaced and publishes InventoryReserved
PaymentService consumes InventoryReserved and publishes PaymentCharged
Cách này đơn giản khi workflow ngắn. Nhưng khi workflow dài, logic bị phân tán, khó debug timeout, retry, compensation, và trạng thái tổng thể.
Orchestration Saga
Trong orchestration, saga orchestrator sở hữu workflow state và gửi command đến từng service.
public sealed class OrderCheckoutState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; } = "";
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
Orchestrator giúp business flow rõ ràng hơn: đang chờ inventory, đang chờ payment, đã timeout, cần compensation, hay đã hoàn tất. Với workflow phức tạp, orchestration thường dễ vận hành hơn choreography.
Idempotency Là Bắt Buộc
Outbox và broker retry thường tạo at-least-once delivery. Message có thể được xử lý nhiều lần.
Consumer nên có inbox hoặc processed-message table:
public sealed class ProcessedMessage
{
public Guid MessageId { get; set; }
public string Consumer { get; set; } = "";
public DateTimeOffset ProcessedAt { get; set; }
}
Nên có unique index trên (MessageId, Consumer) để tránh race condition khi duplicate message đến cùng lúc.
Timeout và Compensation
Saga dài hạn cần timeout. Inventory service có thể không trả lời. Payment provider callback có thể bị delay. Timeout không chỉ là lỗi kỹ thuật, nó là business event.
Compensation không phải rollback. Nó là action mới để đảo ngược ý nghĩa business của action trước đó:
ReleaseInventorycompensateReserveInventory.RefundPaymentcompensateChargePayment.CancelShipmentcompensateCreateShipment.
Nếu một bước không thể compensate tự động, workflow có thể cần human review.
Observability
CQRS + Saga fail theo kiểu phân tán. Cần chuẩn bị observability từ đầu:
- Truyền correlation ID và
traceparentqua mọi command/event. - Log command name, event name, aggregate ID, saga ID, message ID.
- Theo dõi outbox backlog và tuổi của message chưa publish lâu nhất.
- Theo dõi retry count và dead-letter queue.
- Alert saga bị kẹt ở một state quá SLA.
Khi Không Nên Dùng
Không nên dùng CQRS nếu service chỉ là CRUD đơn giản. Không nên tách read/write database quá sớm vì nó tạo synchronization lag và tăng bề mặt vận hành.
Không nên dùng Saga cho operation có thể nằm gọn trong một database transaction. Local transaction vẫn đơn giản và đáng tin cậy hơn.
CQRS phù hợp khi read side và write side có lý do thay đổi khác nhau. Saga phù hợp khi business process đi qua nhiều transactional boundary và cần failure handling rõ ràng.
Kết Luận
Client
-> API
-> MediatR Command
-> Command Handler
-> Aggregate
-> Write Database
-> Outbox
-> Message Broker
-> Saga Orchestrator
-> Service Commands
-> Integration Events
-> Read Model Projections
CQRS làm rõ cấu trúc bên trong service. Saga làm rõ workflow phân tán giữa service. Hai pattern này kết hợp tốt khi bạn giữ invariant trong write model, tối ưu read model cho query, publish event qua outbox, xử lý message idempotent, và xem saga như một workflow có state, timeout, compensation, và observability đầy đủ.