CQRS và Event Sourcing — Khi CRUD không còn đủ
Posted on: 4/21/2026 9:12:00 PM
Table of contents
- Vấn đề với CRUD truyền thống
- CQRS là gì?
- Event Sourcing là gì?
- CQRS + Event Sourcing: Sức mạnh kết hợp
- Event Store: Trái tim của hệ thống
- Snapshot — Tối ưu khi stream quá dài
- Event Versioning — Xử lý schema evolution
- Khi nào KHÔNG nên dùng Event Sourcing?
- Thực tế triển khai: Marten trên .NET
- Xử lý Eventual Consistency ở Frontend
- So sánh CRUD vs CQRS vs CQRS + Event Sourcing
- Checklist triển khai CQRS + Event Sourcing
- Kết luận
- Tham khảo
Hầu hết ứng dụng bắt đầu với mô hình CRUD — tạo, đọc, cập nhật, xoá trên cùng một bảng dữ liệu. Nhưng khi hệ thống phát triển đến hàng triệu request/giây, khi nghiệp vụ đòi hỏi khả năng audit mọi thay đổi, khi bạn cần biết tại sao dữ liệu thay đổi chứ không chỉ dữ liệu hiện tại là gì — CRUD bắt đầu bộc lộ giới hạn nghiêm trọng. Đó là lúc CQRS và Event Sourcing trở thành giải pháp kiến trúc không thể bỏ qua.
Vấn đề với CRUD truyền thống
Trong mô hình CRUD, mỗi khi bạn UPDATE một record, trạng thái cũ bị ghi đè vĩnh viễn. Bạn mất hoàn toàn lịch sử thay đổi. Hãy xem xét một hệ thống e-commerce:
graph LR
A["Đặt hàng"] --> B["orders table
status = pending"]
B --> C["Thanh toán"]
C --> D["orders table
status = paid"]
D --> E["Giao hàng"]
E --> F["orders table
status = shipped"]
style A fill:#e94560,stroke:#fff,color:#fff
style C fill:#e94560,stroke:#fff,color:#fff
style E fill:#e94560,stroke:#fff,color:#fff
style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style F fill:#f8f9fa,stroke:#e94560,color:#2c3e50
CRUD: mỗi UPDATE ghi đè trạng thái trước — không biết khi nào thanh toán, ai duyệt, giá cũ bao nhiêu
Với CRUD, bạn chỉ biết đơn hàng đang ở trạng thái shipped. Nhưng không biết: đơn hàng được đặt lúc mấy giờ? Thanh toán qua cổng nào? Có bao nhiêu lần thay đổi địa chỉ giao hàng? Ai approve đơn hàng VIP? Những câu hỏi này đều vô cùng quan trọng trong thực tế — và CRUD không trả lời được.
CQRS là gì?
Command Query Responsibility Segregation (CQRS) là pattern tách hoàn toàn hai luồng: ghi (Command) và đọc (Query) thành hai model riêng biệt, có thể dùng hai database khác nhau, scale độc lập.
graph TB
subgraph "Command Side (Write)"
CMD["Command Handler"] --> AGG["Aggregate / Domain Model"]
AGG --> WDB[("Write DB
Optimized for consistency")]
end
subgraph "Query Side (Read)"
QH["Query Handler"] --> RM["Read Model / Projection"]
RM --> RDB[("Read DB
Optimized for queries")]
end
WDB -->|"Sync / Async"| RDB
CLIENT["Client"] -->|"Command"| CMD
CLIENT -->|"Query"| QH
style CMD fill:#e94560,stroke:#fff,color:#fff
style QH fill:#4CAF50,stroke:#fff,color:#fff
style AGG fill:#2c3e50,stroke:#fff,color:#fff
style RM fill:#2c3e50,stroke:#fff,color:#fff
style WDB fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style RDB fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
style CLIENT fill:#16213e,stroke:#fff,color:#fff
CQRS: Command side và Query side được tách hoàn toàn, scale độc lập
Tại sao tách Read/Write?
Trong hầu hết hệ thống, tỉ lệ đọc/ghi thường là 10:1 đến 100:1. Khi gộp chung một model, bạn buộc phải thoả hiệp: hoặc model phức tạp để đáp ứng query nhanh (nhưng ghi chậm), hoặc model đơn giản để ghi nhanh (nhưng đọc phải join nhiều bảng). CQRS cho phép bạn tối ưu mỗi bên theo đúng nhu cầu.
Command Side — Đảm bảo tính đúng đắn
Command side nhận các lệnh thay đổi trạng thái: PlaceOrder, ApprovePayment, ShipOrder. Mỗi command đi qua validation, business rules, rồi mới ghi xuống database. Model ở đây được thiết kế để đảm bảo consistency và invariant — không cần quan tâm đến việc hiển thị hay report.
// Command
public record PlaceOrder(Guid OrderId, Guid CustomerId, List<OrderItem> Items);
// Command Handler
public class PlaceOrderHandler
{
private readonly IDocumentSession _session;
public async Task Handle(PlaceOrder cmd)
{
// Business rules validation
if (!cmd.Items.Any())
throw new InvalidOperationException("Order must have at least one item");
var @event = new OrderPlaced(
cmd.OrderId, cmd.CustomerId, cmd.Items, DateTimeOffset.UtcNow);
_session.Events.StartStream<Order>(cmd.OrderId, @event);
await _session.SaveChangesAsync();
}
}
Query Side — Tối ưu cho tốc độ đọc
Query side phục vụ các yêu cầu hiển thị: danh sách đơn hàng, báo cáo doanh thu, dashboard real-time. Read model được thiết kế dạng denormalized — flat, không cần join, có thể là document trong MongoDB, row trong bảng SQL denormalized, hoặc thậm chí cached trong memory.
// Read Model — flat, denormalized, sẵn sàng cho UI
public class OrderSummaryView
{
public Guid OrderId { get; set; }
public string CustomerName { get; set; }
public decimal TotalAmount { get; set; }
public string Status { get; set; }
public int ItemCount { get; set; }
public DateTimeOffset PlacedAt { get; set; }
public DateTimeOffset? ShippedAt { get; set; }
}
// Query Handler — chỉ đọc, không logic nghiệp vụ
public class GetOrderSummaryHandler
{
private readonly IQuerySession _query;
public async Task<OrderSummaryView?> Handle(Guid orderId)
{
return await _query.LoadAsync<OrderSummaryView>(orderId);
}
}
Event Sourcing là gì?
Event Sourcing đảo ngược cách lưu trữ dữ liệu: thay vì lưu trạng thái hiện tại, bạn lưu chuỗi các sự kiện (events) đã xảy ra. Trạng thái hiện tại được tính toán bằng cách replay tất cả events từ đầu.
graph LR
E1["OrderPlaced
10:00 AM"] --> E2["PaymentReceived
10:05 AM"]
E2 --> E3["AddressChanged
10:12 AM"]
E3 --> E4["OrderApproved
10:15 AM"]
E4 --> E5["OrderShipped
2:30 PM"]
E5 --> STATE["Current State:
Shipped ✓
Paid ✓
New Address ✓"]
style E1 fill:#e94560,stroke:#fff,color:#fff
style E2 fill:#e94560,stroke:#fff,color:#fff
style E3 fill:#e94560,stroke:#fff,color:#fff
style E4 fill:#e94560,stroke:#fff,color:#fff
style E5 fill:#e94560,stroke:#fff,color:#fff
style STATE fill:#4CAF50,stroke:#fff,color:#fff
Event Sourcing: trạng thái = f(events) — mọi thay đổi được lưu vĩnh viễn
Định nghĩa Domain Events
Events phải thể hiện ý định nghiệp vụ (domain intent), không phải thao tác kỹ thuật. OrderShipped tốt hơn OrderStatusUpdated. PriceAdjustedForLoyaltyDiscount tốt hơn PriceChanged.
// Domain Events — thể hiện ý định nghiệp vụ rõ ràng
public record OrderPlaced(
Guid OrderId, Guid CustomerId,
List<OrderItem> Items, DateTimeOffset OccurredAt);
public record PaymentReceived(
Guid OrderId, decimal Amount,
string PaymentMethod, string TransactionId, DateTimeOffset OccurredAt);
public record OrderShipped(
Guid OrderId, string TrackingNumber,
string Carrier, DateTimeOffset ShippedAt);
public record OrderCancelled(
Guid OrderId, string Reason,
Guid CancelledBy, DateTimeOffset OccurredAt);
Anti-pattern: Event quá generic
Tránh tạo event dạng OrderUpdated(Dictionary<string, object> changes) — đây là "CRUD trá hình". Event phải mang ý nghĩa nghiệp vụ cụ thể. Nếu bạn không thể đặt tên event bằng ngôn ngữ của domain expert, đó là dấu hiệu thiết kế chưa đúng.
Aggregate — Ranh giới consistency
Aggregate là đơn vị giao dịch trong Event Sourcing. Mỗi aggregate quản lý một stream of events và đảm bảo business invariants:
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public List<OrderItem> Items { get; private set; } = new();
public decimal TotalAmount { get; private set; }
// Command method — validate rồi emit event
public OrderShipped Ship(string trackingNumber, string carrier)
{
if (Status != OrderStatus.Approved)
throw new InvalidOperationException(
$"Cannot ship order in '{Status}' status");
if (string.IsNullOrEmpty(trackingNumber))
throw new ArgumentException("Tracking number is required");
return new OrderShipped(Id, trackingNumber, carrier, DateTimeOffset.UtcNow);
}
// Apply method — cập nhật state từ event (không throw exception)
public void Apply(OrderPlaced e)
{
Id = e.OrderId;
Status = OrderStatus.Placed;
Items = e.Items;
TotalAmount = e.Items.Sum(i => i.Price * i.Quantity);
}
public void Apply(PaymentReceived e) => Status = OrderStatus.Paid;
public void Apply(OrderShipped e) => Status = OrderStatus.Shipped;
public void Apply(OrderCancelled e) => Status = OrderStatus.Cancelled;
}
CQRS + Event Sourcing: Sức mạnh kết hợp
Khi kết hợp CQRS với Event Sourcing, kiến trúc trở nên cực kỳ mạnh mẽ: Command side ghi events, Query side xây dựng các Projection (read model) từ events đó.
graph TB
subgraph "Command Side"
C["Command"] --> CH["Command Handler"]
CH --> A["Aggregate"]
A --> ES[("Event Store
(append-only)")]
end
subgraph "Projection Engine"
ES --> PE["Event Processor"]
PE --> P1["Projection 1
Order Summary"]
PE --> P2["Projection 2
Revenue Report"]
PE --> P3["Projection 3
Customer Dashboard"]
end
subgraph "Query Side"
Q["Query"] --> QH["Query Handler"]
QH --> P1
QH --> P2
QH --> P3
end
style C fill:#e94560,stroke:#fff,color:#fff
style Q fill:#4CAF50,stroke:#fff,color:#fff
style ES fill:#16213e,stroke:#fff,color:#fff
style PE fill:#2c3e50,stroke:#fff,color:#fff
style A fill:#2c3e50,stroke:#fff,color:#fff
style CH fill:#e94560,stroke:#fff,color:#fff
style QH fill:#4CAF50,stroke:#fff,color:#fff
style P1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style P2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style P3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
CQRS + Event Sourcing: events là single source of truth, projections phục vụ đọc
Projection — Xây dựng Read Model từ Events
Projection là quá trình chuyển đổi stream of events thành read model tối ưu cho truy vấn. Với Marten trên .NET, bạn có thể định nghĩa projection rất gọn:
// Single-stream projection: 1 aggregate → 1 read model document
public class OrderSummaryProjection : SingleStreamProjection<OrderSummaryView>
{
public void Apply(OrderPlaced e, OrderSummaryView view)
{
view.OrderId = e.OrderId;
view.CustomerId = e.CustomerId;
view.ItemCount = e.Items.Count;
view.TotalAmount = e.Items.Sum(i => i.Price * i.Quantity);
view.Status = "Placed";
view.PlacedAt = e.OccurredAt;
}
public void Apply(PaymentReceived e, OrderSummaryView view)
{
view.Status = "Paid";
view.PaymentMethod = e.PaymentMethod;
}
public void Apply(OrderShipped e, OrderSummaryView view)
{
view.Status = "Shipped";
view.TrackingNumber = e.TrackingNumber;
view.ShippedAt = e.ShippedAt;
}
}
// Đăng ký projection trong Marten
services.AddMarten(opts =>
{
opts.Projections.Add<OrderSummaryProjection>(ProjectionLifecycle.Inline);
});
Inline vs Async Projection
Inline: projection chạy trong cùng transaction với event — đảm bảo read model luôn consistent, nhưng ghi chậm hơn. Dùng cho use case cần strong consistency.
Async: projection chạy background — ghi nhanh hơn, nhưng read model có thể trễ vài giây (eventual consistency). Dùng cho dashboard, report, analytics.
Cross-stream Projection — Aggregation phức tạp
Nhiều report cần tổng hợp dữ liệu từ nhiều stream khác nhau. Ví dụ: Revenue Dashboard cần tổng hợp từ tất cả đơn hàng:
// Multi-stream projection: tổng hợp events từ nhiều aggregate
public class DailyRevenueProjection : MultiStreamProjection<DailyRevenue, string>
{
public DailyRevenueProjection()
{
Identity<PaymentReceived>(e => e.OccurredAt.ToString("yyyy-MM-dd"));
Identity<OrderCancelled>(e => e.OccurredAt.ToString("yyyy-MM-dd"));
}
public void Apply(PaymentReceived e, DailyRevenue view)
{
view.TotalRevenue += e.Amount;
view.OrderCount++;
}
public void Apply(OrderCancelled e, DailyRevenue view)
{
view.CancelledCount++;
}
}
Event Store: Trái tim của hệ thống
Event Store là cơ sở dữ liệu chuyên biệt cho Event Sourcing. Nó khác database thông thường ở chỗ: chỉ append, không update, không delete.
| Tiêu chí | EventStoreDB | Marten (PostgreSQL) | SQL Server + Custom |
|---|---|---|---|
| Loại | Purpose-built event store | Document DB + Event Store trên PostgreSQL | Tự xây trên SQL Server |
| Stream subscription | Built-in (catch-up, persistent) | Built-in (async daemon) | Tự implement (polling/CDC) |
| Projection | Built-in JavaScript projections | Built-in .NET projections | Tự implement |
| Concurrency | Optimistic (stream version) | Optimistic (stream version) | Tự implement |
| Phù hợp | Hệ thống lớn, event-native | .NET ecosystem, PostgreSQL sẵn có | Khi bắt buộc dùng SQL Server |
| Learning curve | Trung bình | Thấp (nếu đã quen .NET) | Cao (phải tự xây mọi thứ) |
Schema Event Store trên SQL Server
Nếu team bạn bắt buộc dùng SQL Server, đây là schema tối thiểu:
CREATE TABLE EventStore (
SequenceNumber BIGINT IDENTITY(1,1) PRIMARY KEY,
StreamId NVARCHAR(200) NOT NULL,
StreamVersion INT NOT NULL,
EventType NVARCHAR(500) NOT NULL,
Payload NVARCHAR(MAX) NOT NULL, -- JSON serialized event
Metadata NVARCHAR(MAX) NULL, -- correlation, causation, user info
CreatedAt DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
CONSTRAINT UQ_Stream_Version UNIQUE (StreamId, StreamVersion)
);
CREATE INDEX IX_EventStore_StreamId ON EventStore(StreamId, StreamVersion);
CREATE INDEX IX_EventStore_EventType ON EventStore(EventType);
CREATE INDEX IX_EventStore_CreatedAt ON EventStore(CreatedAt);
Constraint UQ_Stream_Version là chìa khoá cho optimistic concurrency: nếu hai command cùng ghi version 5 cho một stream, chỉ một cái thành công, cái còn lại nhận conflict error.
Snapshot — Tối ưu khi stream quá dài
Khi một aggregate có hàng nghìn events, replay từ đầu sẽ rất chậm. Snapshot lưu trạng thái tại một thời điểm nhất định, lần sau chỉ cần replay events SAU snapshot.
graph LR
E1["Event 1"] --> E2["Event 2"]
E2 --> E3["..."]
E3 --> E500["Event 500"]
E500 --> S["📸 Snapshot
tại version 500"]
S --> E501["Event 501"]
E501 --> E502["Event 502"]
E502 --> E503["Event 503"]
E503 --> STATE["Current State
(chỉ replay 3 events)"]
style S fill:#4CAF50,stroke:#fff,color:#fff
style STATE fill:#e94560,stroke:#fff,color:#fff
style E500 fill:#2c3e50,stroke:#fff,color:#fff
style E501 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style E502 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style E503 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Snapshot: thay vì replay 503 events, chỉ cần load snapshot + replay 3 events cuối
// Cấu hình snapshot trong Marten
services.AddMarten(opts =>
{
opts.Events.AddEventType<OrderPlaced>();
opts.Events.AddEventType<PaymentReceived>();
opts.Events.AddEventType<OrderShipped>();
// Tạo snapshot sau mỗi 100 events
opts.Projections.Snapshot<Order>(SnapshotLifecycle.Inline, 100);
});
Event Versioning — Xử lý schema evolution
Events là bất biến — một khi đã lưu thì không sửa. Nhưng business thay đổi, bạn cần thêm field, đổi cấu trúc. Event versioning giải quyết vấn đề này:
Strategy 1: Upcasting
Chuyển đổi event cũ sang format mới khi đọc, không sửa dữ liệu gốc:
// Version 1: ban đầu chỉ có Amount
public record PaymentReceived_V1(Guid OrderId, decimal Amount, DateTimeOffset OccurredAt);
// Version 2: thêm Currency
public record PaymentReceived(
Guid OrderId, decimal Amount, string Currency, DateTimeOffset OccurredAt);
// Upcaster: chuyển V1 → V2 khi đọc
public class PaymentReceivedUpcaster : EventUpcaster<PaymentReceived_V1, PaymentReceived>
{
protected override PaymentReceived Upcast(PaymentReceived_V1 old)
{
return new PaymentReceived(old.OrderId, old.Amount, "VND", old.OccurredAt);
}
}
Strategy 2: Weak schema
Dùng optional fields và giá trị mặc định, tránh breaking change:
public record OrderPlaced(
Guid OrderId,
Guid CustomerId,
List<OrderItem> Items,
DateTimeOffset OccurredAt,
string? PromotionCode = null, // thêm sau — event cũ không có field này
string? Channel = "web" // thêm sau — default = "web"
);
Khi nào KHÔNG nên dùng Event Sourcing?
Event Sourcing không phải silver bullet
Đây là pattern phức tạp, đòi hỏi team hiểu rõ domain và có kinh nghiệm. Dùng sai chỗ sẽ tạo ra complexity vô ích.
| Phù hợp | Không phù hợp |
|---|---|
| Nghiệp vụ tài chính, ngân hàng — cần audit trail 100% | CRUD đơn giản — blog, CMS, landing page |
| Hệ thống cộng tác nhiều người (collaborative editing) | Ứng dụng ít user, ít thay đổi trạng thái |
| Cần replay / time-travel (debug, compliance) | Team chưa hiểu DDD và domain modeling |
| Write-heavy system cần scale ghi | Prototype, MVP giai đoạn đầu |
| Microservices cần event-driven integration | Dữ liệu cần xoá vĩnh viễn (GDPR right-to-erasure) |
Thực tế triển khai: Marten trên .NET
Marten là thư viện .NET mạnh mẽ nhất hiện tại cho Event Sourcing, chạy trên PostgreSQL — không cần database chuyên biệt:
// Program.cs — Setup Marten
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMarten(opts =>
{
opts.Connection(builder.Configuration.GetConnectionString("Postgres")!);
// Event types
opts.Events.AddEventType<OrderPlaced>();
opts.Events.AddEventType<PaymentReceived>();
opts.Events.AddEventType<OrderShipped>();
opts.Events.AddEventType<OrderCancelled>();
// Inline projection — consistent read model
opts.Projections.Add<OrderSummaryProjection>(ProjectionLifecycle.Inline);
// Async projection — eventual consistency, chạy background
opts.Projections.Add<DailyRevenueProjection>(ProjectionLifecycle.Async);
// Snapshot mỗi 200 events
opts.Projections.Snapshot<Order>(SnapshotLifecycle.Inline, 200);
})
.AddAsyncDaemon(DaemonMode.HotCold); // chạy async projections
Full workflow: Đặt hàng → Thanh toán → Giao hàng
// API Endpoint — Place Order
app.MapPost("/orders", async (PlaceOrder cmd, IDocumentSession session) =>
{
var order = new Order();
var @event = order.Place(cmd.CustomerId, cmd.Items);
session.Events.StartStream<Order>(cmd.OrderId, @event);
await session.SaveChangesAsync();
return Results.Created($"/orders/{cmd.OrderId}", new { cmd.OrderId });
});
// API Endpoint — Ship Order
app.MapPost("/orders/{id}/ship", async (Guid id, ShipRequest req, IDocumentSession session) =>
{
var order = await session.Events.AggregateStreamAsync<Order>(id)
?? throw new NotFoundException($"Order {id} not found");
var @event = order.Ship(req.TrackingNumber, req.Carrier);
session.Events.Append(id, @event);
await session.SaveChangesAsync();
return Results.Ok();
});
// API Endpoint — Query order summary
app.MapGet("/orders/{id}/summary", async (Guid id, IQuerySession query) =>
{
var summary = await query.LoadAsync<OrderSummaryView>(id);
return summary is null ? Results.NotFound() : Results.Ok(summary);
});
Xử lý Eventual Consistency ở Frontend
Khi dùng async projection, read model có thể trễ vài giây so với write. Có ba cách xử lý phổ biến:
So sánh CRUD vs CQRS vs CQRS + Event Sourcing
| Tiêu chí | CRUD | CQRS | CQRS + Event Sourcing |
|---|---|---|---|
| Lưu trữ | Trạng thái hiện tại | Trạng thái hiện tại (2 DB) | Chuỗi events bất biến |
| Audit trail | Không (trừ khi thêm audit table) | Không tự động | Có sẵn — mọi thay đổi đều là event |
| Scale đọc/ghi | Gắn chặt nhau | Scale độc lập | Scale độc lập + append-only write |
| Complexity | Thấp | Trung bình | Cao |
| Time travel | Không | Không | Có — replay events đến bất kỳ thời điểm |
| Phù hợp | CRUD apps, MVP | Hệ thống read-heavy | Hệ thống tài chính, collaborative, event-driven |
Checklist triển khai CQRS + Event Sourcing
Kết luận
CQRS và Event Sourcing không phải pattern dành cho mọi dự án, nhưng khi business đòi hỏi audit trail hoàn chỉnh, khả năng scale read/write độc lập, hoặc integration event-driven giữa các service — chúng là kiến trúc đáng đầu tư. Với hệ sinh thái .NET, Marten + PostgreSQL là lựa chọn thực tế nhất để bắt đầu mà không cần infrastructure phức tạp. Hãy bắt đầu với một bounded context nhỏ, chứng minh giá trị, rồi mở rộng dần.
Tham khảo
System Design: Chat Real-time quy mô triệu user
Server-Sent Events — Xây dựng Real-time Dashboard với .NET 10, Vue 3 & Redis
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.