CQRS và Event Sourcing 2026 - Kiến trúc Event-Driven với .NET 10, Wolverine, Marten, Outbox Pattern và Saga cho Microservices

Posted on: 4/16/2026 11:11:43 AM

Table of contents

  1. 1. Vì sao CQRS và Event Sourcing trở lại ánh đèn sân khấu năm 2026
  2. 2. CQRS - Phân tách Command và Query là tinh thần, không phải kiến trúc bắt buộc
    1. Nguyên tắc áp dụng CQRS tối thiểu khả thi
  3. 3. Event Sourcing - Lịch sử là nguồn sự thật, state chỉ là một view tạm thời
    1. Cảnh báo: Event Sourcing không phải Message Broker
  4. 4. Aggregate, Stream và Event - Ngôn ngữ DDD gặp Event Store
  5. 5. Projection và Read Model - Xây dựng thế giới song song cho query
    1. Thủ thuật rebuild không downtime
  6. 6. Outbox Pattern - Giải bài toán "dual write" một lần và dứt điểm
    1. Outbox không giải quyết mọi vấn đề
  7. 7. EventStoreDB, Marten, Axon hay PostgreSQL custom - Chọn runtime nào
  8. 8. Triển khai thực tế trên .NET 10 - Wolverine, Marten và MediatR 13
  9. 9. Schema Evolution, Upcasting và Snapshot - Vấn đề sống còn với stream dài hạn
    1. Nguyên tắc versioning thực dụng
  10. 10. Saga Pattern - Điều phối giao dịch phân tán trên nền event
  11. 11. Những cái bẫy production mà các slide không nhắc tới
    1. 11.1. Eventual consistency không phải excuse cho UX lỗi
    2. 11.2. Idempotency key không phải option
    3. 11.3. Projection chậm không có nghĩa Event Store chậm
    4. 11.4. Stream bloat - không phải mọi thứ đều cần là event
    5. 11.5. Snapshot không miễn phí
      1. Hai điều không nên làm trong năm đầu
  12. 12. Tích hợp với Kafka 4.0 - Khi event tích hợp gặp event domain
  13. 13. Kết luận và lộ trình áp dụng thực tế
  14. Nguồn tham khảo

1. Vì sao CQRS và Event Sourcing trở lại ánh đèn sân khấu năm 2026

Có một chu kỳ rất rõ trong cộng đồng kỹ sư phần mềm: cứ mỗi khoảng bốn tới năm năm, những khái niệm tưởng như đã được xếp vào kệ sách "kiến trúc nâng cao" lại trở về, lần này với ít hype hơn, nhiều công cụ sản xuất hơn, và những kỹ sư đã bị một vài sự cố production dạy cho bài học đủ đau. Năm 2026, CQRSEvent Sourcing đang ở đúng giai đoạn đó. Chúng không còn là thứ chỉ các hệ thống giao dịch tài chính hay lĩnh vực y tế quy định khắt khe mới dám chạm vào, mà đã trở thành lựa chọn mặc định cho một lớp microservices có yêu cầu audit cao, cần projection thời gian thực cho dashboard, và muốn giải một lần dứt điểm bài toán "dual write" giữa database và message broker.

Điều khiến hai pattern này quan trọng trở lại không phải là một breakthrough lý thuyết mới, mà là hệ sinh thái công cụ quanh nó đã trưởng thành. Wolverine 3.0 cho .NET đã biến handler cho command và event trở thành C# code gần như không cần framework glue, Marten 7 biến PostgreSQL thành một Event Store quasi-native với append optimistic concurrency và projection streaming, EventStoreDB 25.0 ra mắt KurrentDB engine mới với throughput cao hơn ba lần. Ở phía Java, Axon Framework 5 hợp nhất command bus, event bus và saga manager vào một runtime thống nhất. Công cụ đã có, còn lại là câu hỏi: khi nào thực sự nên dùng, và làm thế nào để triển khai trên .NET 10 mà không rơi vào bẫy "kiến trúc quá mức" vốn đã giết nhiều dự án từ 2015 tới nay.

3.2xThroughput append của EventStoreDB 25 so với 24 theo benchmark chính thức
150kEvent/giây trên một node PostgreSQL 18 dùng Marten 7 append-only
<5msP95 replay projection từ stream 10 triệu event trên SSD NVMe
0Lost message nếu triển khai đúng Outbox Pattern kèm idempotent consumer

Bài viết này không phải một bài giới thiệu pattern ở mức khái niệm, mà là một cuộc khám phá toàn bộ chuỗi quyết định kiến trúc mà bạn phải trải qua khi muốn chạy CQRS + Event Sourcing + Outbox Pattern cho một microservice .NET 10 thực tế: từ việc chọn store, thiết kế aggregate, xây projection, xử lý schema evolution, điều phối saga, cho tới những cái bẫy production mà các slide tại hội thảo gần như không bao giờ nhắc tới.

2. CQRS - Phân tách Command và Query là tinh thần, không phải kiến trúc bắt buộc

Hiểu lầm lớn nhất về CQRS là cho rằng nó đồng nghĩa với "database đọc riêng, database ghi riêng". Thực tế, Greg Young - người đặt tên cho pattern này - đã nhiều lần nhấn mạnh: CQRS chỉ đơn giản là việc hai model khác nhau được sinh ra, một model dùng để ghi và một model dùng để đọc, và chúng không nhất thiết phải nằm ở hai nơi lưu trữ khác nhau. Sự tách biệt này bắt nguồn từ một nhận xét thực dụng: cùng một domain, nghiệp vụ ghi (cập nhật trạng thái, đảm bảo invariant) và nghiệp vụ đọc (hiển thị danh sách, lọc, báo cáo) gần như luôn cần shape dữ liệu hoàn toàn khác nhau, và cố nhồi cả hai vào cùng một model domain rich là con đường ngắn nhất tới một ORM mapping phức tạp mà không ai dám động vào.

Trong thế giới .NET truyền thống, một microservice "đơn giản" thường có duy nhất một Entity Framework DbContext, với các phương thức repository vừa làm nhiệm vụ ghi vừa làm nhiệm vụ truy vấn. Khi hệ thống lớn lên, các phương thức query phình ra thành những Include dài bốn dòng, JOIN xuyên bảy bảng, đôi khi kèm projection LINQ thủ công. Áp dụng CQRS ở mức nhẹ - tức là giữ một DbContext duy nhất nhưng tách handler command và handler query thành hai tập lớp - đã đủ giải quyết 80% nỗi đau. Greg Young gọi đây là "CQRS với một database".

Nguyên tắc áp dụng CQRS tối thiểu khả thi

Nếu bạn chưa có nhu cầu Event Sourcing, bắt đầu bằng việc tách ICommandHandler và IQueryHandler trong cùng một dự án, dùng chung một DbContext. Các query dùng AsNoTracking, các command dùng Tracking. Bạn đã có được phần lớn lợi ích CQRS mà không phải đối mặt với eventual consistency. Chỉ mở rộng sang read store riêng khi có dấu hiệu rõ ràng: báo cáo thời gian thực, full-text search, hoặc cần shape dữ liệu hoàn toàn khác.

CQRS "đầy đủ" - với một write model viết trên Event Store và một read model là các projection được xây dựng ra Elasticsearch hoặc PostgreSQL - chỉ thực sự cần thiết khi bạn gặp một trong các tín hiệu sau: (1) nghiệp vụ ghi và nghiệp vụ đọc có tần suất lệch nhau trên một bậc (ví dụ một hệ thống e-commerce có 10.000 truy vấn/giây cho đọc nhưng chỉ 100 command/giây cho ghi), (2) dữ liệu đọc cần được tổng hợp từ nhiều aggregate theo cách mà model ghi không thể trả lời hiệu quả, (3) bạn cần nhiều view của cùng một dữ liệu (dashboard thời gian thực, API cho mobile, báo cáo kế toán) và mỗi view cần shape khác nhau, hoặc (4) quy định buộc phải audit mọi thay đổi, tức là Event Sourcing trở thành lựa chọn cưỡng bức hơn là tùy chọn.

flowchart LR
    Client[Client / UI]
    API[ASP.NET Core 10 API]
    CmdBus[Command Bus
Wolverine] AggRoot[Aggregate Root
Domain Model] EvtStore[(Event Store
append-only)] Proj[Projection Handler] ReadDB[(Read Model
PostgreSQL)] QryBus[Query Bus] Client -- POST command --> API API --> CmdBus CmdBus --> AggRoot AggRoot -- new events --> EvtStore EvtStore -. stream .-> Proj Proj --> ReadDB Client -- GET query --> API API --> QryBus QryBus --> ReadDB
Hình 1: Luồng CQRS đầy đủ với Event Sourcing - ghi qua aggregate, đọc qua read model

3. Event Sourcing - Lịch sử là nguồn sự thật, state chỉ là một view tạm thời

Trái tim của Event Sourcing là một lựa chọn triết học về bản chất của dữ liệu. Trong mô hình CRUD truyền thống, database lưu trạng thái hiện tại và mỗi UPDATE ghi đè lên quá khứ. Trong Event Sourcing, database chỉ lưu một chuỗi các sự kiện đã xảy ra (append-only), còn trạng thái hiện tại là kết quả của việc "phát lại" chuỗi sự kiện đó qua một hàm fold. Nói cách khác, thay vì lưu "tài khoản đang có 1.200.000 đồng", bạn lưu "đã nạp 500.000", "đã nạp 800.000", "đã rút 100.000", và số dư 1.200.000 là kết quả tổng hợp từ ba sự kiện đó.

Điểm mạnh của lựa chọn này không chỉ nằm ở audit. Khi bạn có toàn bộ lịch sử sự kiện, bạn có thể tái tạo state ở bất kỳ thời điểm nào trong quá khứ (time travel), phân tích hành vi người dùng theo cách không ai lường trước được lúc thiết kế (bởi các sự kiện giàu ngữ cảnh hơn state), và thêm projection mới bằng cách replay toàn bộ stream mà không cần migration phức tạp. Một dashboard cần tính "số đơn hàng trung bình mỗi khách trong tháng qua" có thể được triển khai mà không phải thay đổi schema ghi - bạn viết một projection mới, phát lại stream, và có kết quả.

Nhưng Event Sourcing không miễn phí. Mỗi quyết định bạn ra đều có cái giá của nó. Đổi lại lịch sử đầy đủ, bạn phải chấp nhận: (1) mô hình ghi trở nên phức tạp hơn vì mọi thay đổi phải biểu diễn được dưới dạng event, (2) query trực tiếp trên event gần như không khả thi, bạn buộc phải xây projection, (3) schema evolution không còn là ALTER TABLE mà trở thành một bài toán upcasting hoặc multiple version của cùng một event, (4) snapshot trở thành một cơ chế bắt buộc nếu stream của bạn vượt quá vài nghìn event. Các trade-off này không phải rào cản, nhưng là lý do Event Sourcing chỉ nên áp dụng cho các bounded context thực sự hưởng lợi từ lịch sử giàu ngữ cảnh, không phải toàn bộ hệ thống.

Cảnh báo: Event Sourcing không phải Message Broker

Một trong những hiểu lầm phổ biến là trộn lẫn event trong Event Sourcing với message trên Kafka hay RabbitMQ. Event trong Event Sourcing là domain event gắn chặt với một aggregate cụ thể, được ghi synchronously trong transaction với state, và không bao giờ thay đổi sau khi append. Message trên broker có thể là event tích hợp (integration event) được phát ra từ aggregate sau khi state đã commit, và chịu trách nhiệm thông báo cho bounded context khác. Nhầm lẫn hai loại event này dẫn tới kiến trúc lai dị trong đó Kafka trở thành event store, một lựa chọn có hậu quả thực tế là bạn mất transactional guarantees và phải tự phát minh lại ordering.

4. Aggregate, Stream và Event - Ngôn ngữ DDD gặp Event Store

Trong Event Sourcing, đơn vị consistency không phải là bảng database, mà là aggregate theo định nghĩa DDD: một cụm các entity và value object chia sẻ một root, có ranh giới nghiệp vụ rõ ràng và được đảm bảo toàn vẹn trong một transaction. Mỗi aggregate instance tương ứng với một stream trong event store - một chuỗi các event có thứ tự, được định danh bởi một stream id duy nhất. Khi một command tới, aggregate được rehydrate bằng cách load stream đó về, fold các event thành state hiện tại, kiểm tra invariant, sinh event mới và append vào cuối stream với optimistic concurrency (version number).

Có một quyết định thiết kế rất quan trọng ở đây: ranh giới aggregate quyết định ranh giới transaction. Nếu bạn thiết kế aggregate quá to - ví dụ một Order chứa toàn bộ danh mục sản phẩm liên quan - stream của nó sẽ phình rất nhanh, snapshot phải xây lại thường xuyên, và optimistic concurrency conflict sẽ tăng vọt khi nhiều người dùng thao tác cùng lúc. Ngược lại, nếu aggregate quá nhỏ - ví dụ một OrderLine tách khỏi Order - bạn sẽ cần saga hoặc process manager để đảm bảo toàn vẹn liên aggregate, đẩy phức tạp sang tầng khác.

flowchart TB
    subgraph Aggregate["Aggregate: Order #1234"]
        direction TB
        State["Current State
(reconstructed)"] E1[OrderPlaced v1] E2[ItemAdded v2] E3[ItemAdded v3] E4[DiscountApplied v4] E5[OrderConfirmed v5] E1 --> E2 --> E3 --> E4 --> E5 --> State end Cmd[Command: ConfirmOrder] --> Load[Load stream] Load --> Aggregate State --> Check[Check invariants] Check --> NewEvt[New Event: OrderConfirmed] NewEvt --> Append[Append to stream
expected version = 4] Append --> Store[(Event Store)]
Hình 2: Aggregate rehydrate từ stream, xử lý command và append event mới với optimistic version

Trong Wolverine 3.0 dành cho .NET 10, cách viết một aggregate hướng event trở nên ngắn gọn một cách đáng kinh ngạc. Bạn định nghĩa aggregate như một class POCO với các method thay vì một rich entity Entity Framework, và các handler được tự động tìm thấy qua source generator. Đoạn code minh họa dưới đây là một OrderAggregate tối giản:

public record OrderPlaced(Guid OrderId, Guid CustomerId, DateTime OccurredAt);
public record ItemAdded(Guid OrderId, string Sku, int Qty, decimal Price);
public record DiscountApplied(Guid OrderId, decimal Percent);
public record OrderConfirmed(Guid OrderId, decimal TotalAmount);

public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public List<OrderLine> Lines { get; } = new();
    public decimal DiscountPercent { get; private set; }
    public bool Confirmed { get; private set; }
    public int Version { get; private set; }

    public void Apply(OrderPlaced e)    { Id = e.OrderId; CustomerId = e.CustomerId; Version++; }
    public void Apply(ItemAdded e)      { Lines.Add(new OrderLine(e.Sku, e.Qty, e.Price)); Version++; }
    public void Apply(DiscountApplied e){ DiscountPercent = e.Percent; Version++; }
    public void Apply(OrderConfirmed e) { Confirmed = true; Version++; }
}

public static class OrderCommandHandler
{
    public static OrderConfirmed Handle(ConfirmOrder cmd, Order state)
    {
        if (state.Confirmed) throw new InvalidOperationException("Already confirmed");
        if (state.Lines.Count == 0) throw new InvalidOperationException("Empty order");
        var total = state.Lines.Sum(l => l.Price * l.Qty) * (1 - state.DiscountPercent / 100m);
        return new OrderConfirmed(cmd.OrderId, total);
    }
}

Điểm đáng chú ý: handler là một pure function từ (command, state) sang event mới. Không có dependency injection, không có side effect ngoài việc sinh event. Điều này không chỉ dễ test bằng cách gọi thẳng hàm mà còn mở đường cho tối ưu: Wolverine có thể batch command, chạy song song các handler không liên quan, và thậm chí relocate execution sang node khác mà không cần thay đổi code.

5. Projection và Read Model - Xây dựng thế giới song song cho query

Projection là nơi Event Sourcing gặp CQRS. Sau khi event được append vào store, một hoặc nhiều projection handler sẽ đọc event đó và cập nhật các read model tối ưu cho query. Một hệ thống trưởng thành thường có vài chục projection khác nhau, mỗi projection phục vụ một mục đích: một projection dành cho màn hình chi tiết đơn hàng, một projection cho danh sách đơn hàng của khách, một projection tổng hợp doanh thu theo ngày, một projection feed dữ liệu vào kho phân tích, và cứ thế.

Có hai chế độ projection cơ bản mà bạn cần phân biệt rõ: inline projection chạy đồng bộ trong cùng transaction với append event, đảm bảo read-your-own-writes nhưng đánh đổi thông lượng; và async projection chạy sau, theo pull hoặc push, chấp nhận eventual consistency nhưng scale tốt hơn nhiều. Với Marten 7 trên PostgreSQL 18, bạn có lựa chọn thứ ba: daemon projection chạy trong một background worker, commit tự động và tự động replay khi thêm mới, với latency điển hình dưới 50ms.

Loại projectionĐộ trễThroughputRead-your-writesUse case điển hình
Inline (cùng transaction)<1msThấpBảo đảmMàn hình sau submit form
Daemon async (Marten)10-50msCaoGần như bảo đảmDashboard, list view
Kafka Streams consumer100-500msRất caoKhôngAnalytics, tổng hợp liên service
Batch rebuild qua replayPhút tới giờKhông liên quanKhôngThêm view mới, sửa bug projection

Sức mạnh lớn nhất của projection chính là khả năng rebuild từ zero. Khi bạn phát hiện một projection có bug - ví dụ tính sai tổng tiền vì quên áp dụng giảm giá - cách xử lý không phải là viết migration script để sửa dữ liệu đang sai trong read store, mà là sửa code projection, xóa sạch read store, và replay toàn bộ stream từ đầu. Với 10 triệu event trên SSD NVMe và Marten 7, một rebuild điển hình mất từ vài phút tới vài chục phút tùy độ phức tạp, và sau đó read store hoàn toàn đúng mà không cần bất kỳ data fix nào.

Thủ thuật rebuild không downtime

Khi cần rebuild một projection lớn trong production, không rebuild trực tiếp lên read store đang phục vụ. Thay vào đó, tạo một version mới của read store (ví dụ đổi hậu tố order_summary_v2), rebuild toàn bộ lên version mới, sau đó atomic swap bằng cách đổi view hoặc symlink. Với PostgreSQL, bạn có thể dùng schema riêng và ALTER SCHEMA RENAME trong một transaction duy nhất. Với Elasticsearch, dùng alias và POST _aliases để đổi target.

6. Outbox Pattern - Giải bài toán "dual write" một lần và dứt điểm

Một trong những lỗi kiến trúc phổ biến nhất trong microservices là dual write: trong cùng một command handler, bạn ghi vào database rồi publish một message lên Kafka. Nhìn qua có vẻ ổn, nhưng vấn đề xuất hiện khi một trong hai thao tác thất bại. Nếu database commit thành công nhưng Kafka down, message không bao giờ được phát, và bounded context khác không biết sự thay đổi đã xảy ra. Ngược lại, nếu bạn publish trước rồi commit database, message có thể đến tai consumer trước khi state thực sự được ghi, dẫn tới inconsistency kiểu khác. Và điều đáng sợ nhất là các sự cố này không xảy ra thường xuyên đủ để test bắt được, nhưng đủ để gây hậu quả lớn khi xảy ra trong production.

Outbox Pattern là câu trả lời kinh điển cho bài toán này. Thay vì publish message trực tiếp, bạn ghi message vào một bảng Outbox nằm cùng database với aggregate, trong cùng một transaction. Sau đó một background process đọc bảng Outbox và publish lên broker, đánh dấu row đã xử lý. Vì append event và ghi Outbox cùng transaction, bạn có atomic guarantee: hoặc cả hai commit, hoặc cả hai rollback. Nếu broker down, message vẫn nằm nguyên trong Outbox và sẽ được phát lại khi broker khôi phục.

sequenceDiagram
    participant App as .NET 10 App
    participant DB as PostgreSQL
    participant Relay as Outbox Relay
    participant Kafka
    participant Consumer as Other Service

    App->>DB: BEGIN TX
    App->>DB: Append event to Stream
    App->>DB: INSERT INTO Outbox
    App->>DB: COMMIT
    Note over App,DB: Atomic: state + outbox

    loop Poll or CDC
        Relay->>DB: SELECT unpublished
        Relay->>Kafka: Publish
        Kafka-->>Relay: ack
        Relay->>DB: UPDATE published_at
    end

    Kafka->>Consumer: Deliver
    Consumer->>Consumer: Idempotent handle
Hình 3: Outbox Pattern - atomic commit giữa state và message, relay tách rời với broker

Có hai biến thể triển khai Outbox phổ biến trên .NET 10:

  • Polling Outbox - background worker SELECT từ bảng Outbox mỗi vài trăm ms. Đơn giản, không phụ thuộc ngoài, nhưng có độ trễ cố định và tốn query dù không có gì để phát. Phù hợp với throughput thấp tới trung bình, dưới vài nghìn message/giây.
  • CDC-based Outbox - dùng Debezium hoặc PostgreSQL logical replication để stream thay đổi từ bảng Outbox thẳng vào Kafka. Độ trễ rất thấp (vài ms), throughput cao, nhưng thêm một thành phần hạ tầng phải vận hành. Phù hợp với hệ thống ưu tiên độ trễ thấp.

Wolverine 3.0 đi một bước xa hơn: nó tự động biến mọi publish call thành ghi Outbox nếu bạn bật flag UseOutboxOnSending, và có sẵn một relay chạy trong process. MassTransit v9 có cơ chế tương tự gọi là In-Memory Outbox kết hợp với database Outbox. Điểm chung là: bạn không phải tự viết bảng Outbox, không phải tự viết relay, chỉ cần bật flag và đặt tên schema. Nhưng cảnh giác: việc hai framework tự lo Outbox không có nghĩa bạn không cần hiểu nó. Một khi sự cố xảy ra - message bị stuck, duplicate, hoặc mất thứ tự - bạn buộc phải can thiệp tay và lúc đó kiến thức về cơ chế bên dưới quyết định thời gian phục hồi.

Outbox không giải quyết mọi vấn đề

Outbox đảm bảo at-least-once delivery, không phải exactly-once. Điều này có nghĩa consumer phía nhận vẫn phải idempotent: có thể nhận cùng một message hai lần, và phải xử lý đúng. Cách triển khai idempotency phổ biến là lưu một bảng processed_message với unique constraint trên message id, hoặc dùng idempotency key trong chính business logic. Nếu bạn dùng Outbox mà quên idempotent consumer, bạn đã không thực sự giải quyết bài toán consistency - chỉ đẩy nó qua phía bên kia.

7. EventStoreDB, Marten, Axon hay PostgreSQL custom - Chọn runtime nào

Khi quyết định triển khai Event Sourcing nghiêm túc, câu hỏi đầu tiên là chọn engine làm event store. Năm 2026, có bốn lựa chọn đáng cân nhắc trên stack .NET 10, mỗi lựa chọn mang theo một triết lý vận hành khác nhau.

EngineNền tảngĐiểm mạnhĐiểm yếuKhi nào chọn
EventStoreDB 25 (Kurrent)Stack riêng, engine nativeThroughput append cực cao, projection engine built-in, persistent subscription, category stream tự nhiênThêm một loại database vào ops stack, community nhỏ hơn PostgreSQL, tooling riêngEvent store là trọng tâm của hệ thống, bạn sẵn sàng đầu tư vào một engine chuyên dụng
Marten 7PostgreSQL 18Dùng PostgreSQL có sẵn, projection daemon ổn định, tích hợp Wolverine liền mạch, upgrade cùng .NETBị giới hạn bởi PostgreSQL performance ceiling, không phù hợp khi append vượt 200k/giây một nodeTeam đã có kinh nghiệm PostgreSQL, muốn giảm số lượng công nghệ phải vận hành
Axon Framework 5JVM, có client .NET qua connectorHệ sinh thái trưởng thành, command/event/saga thống nhất, Axon Server multi-tenantStack Java là rào cản cho team .NET thuần, tích hợp qua connector thêm độ phức tạpHệ thống heterogenous Java/.NET, muốn một event store chia sẻ
Custom PostgreSQLPostgreSQL + EF Core hoặc DapperToàn quyền kiểm soát, không framework lock-in, dễ debug, chi phí bằng khôngTự xây projection, snapshot, upcasting, subscription - tốn thời gian và dễ saiNhu cầu đơn giản, cần học sâu pattern, hoặc có ràng buộc compliance cấm dùng framework

Theo kinh nghiệm thực chiến với các đội .NET Việt Nam cỡ 10-30 kỹ sư, Marten 7 trên PostgreSQL 18 là điểm ngọt ngào nhất. Lý do không phải vì nó mạnh nhất, mà vì nó tối thiểu hóa blast radius: cùng một database, cùng một công cụ backup, cùng một kỹ năng vận hành. Thêm nữa, Marten 7 đi kèm Wolverine 3.0 tạo thành một stack thuần C# không có framework JVM nào, và các advanced feature như inline/async projection, snapshot, upcasting đều được framework cover. Chuyển sang EventStoreDB nên là quyết định có cơ sở số liệu cụ thể: throughput append vượt ngưỡng PostgreSQL, không phải vì "nó nghe có vẻ chuyên nghiệp hơn".

8. Triển khai thực tế trên .NET 10 - Wolverine, Marten và MediatR 13

Một stack điển hình cho microservice CQRS + Event Sourcing trên .NET 10 tháng 4/2026 trông như sau. Host là ASP.NET Core Minimal API hoặc Fast Endpoints, tầng mediator là Wolverine 3.0 (đã thay thế MediatR trong phần lớn dự án mới), event store là Marten 7 trên PostgreSQL 18, read store là bảng PostgreSQL thường hoặc Elasticsearch tùy nhu cầu search, message broker là Kafka 4.0 KRaft hoặc RabbitMQ Streams. Toàn bộ được orchestrate bởi .NET Aspire 9.5, và deploy lên Kubernetes qua Helm chart sinh từ Aspire manifest.

Đoạn code dưới đây minh họa cấu hình Program.cs cho một microservice Order với đầy đủ Wolverine + Marten:

using Wolverine;
using Wolverine.Marten;
using Marten;
using Marten.Events.Projections;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMarten(opts =>
{
    opts.Connection(builder.Configuration.GetConnectionString("Postgres")!);
    opts.Events.StreamIdentity = StreamIdentity.AsGuid;
    opts.UseSystemTextJsonForSerialization();

    opts.Projections.Add<OrderSummaryProjection>(ProjectionLifecycle.Async);
    opts.Projections.Add<OrderRevenueProjection>(ProjectionLifecycle.Async);
    opts.Projections.Snapshot<OrderAggregate>(SnapshotLifecycle.Inline);
})
.UseLightweightSessions()
.IntegrateWithWolverine()
.ApplyAllDatabaseChangesOnStartup()
.AddAsyncDaemon(DaemonMode.Solo);

builder.Host.UseWolverine(opts =>
{
    opts.UseKafka(builder.Configuration.GetSection("Kafka"))
        .AutoProvision()
        .ConfigureListeners(l => l.UseDurableInbox())
        .ConfigureSenders(s => s.UseDurableOutbox());

    opts.Policies.AutoApplyTransactions();
    opts.Policies.UseDurableInboxOnAllListeners();
    opts.Policies.UseDurableOutboxOnAllSenders();
});

var app = builder.Build();

app.MapPost("/orders/{id}/confirm",
    (Guid id, ConfirmOrderRequest req, IMessageBus bus) =>
        bus.InvokeAsync(new ConfirmOrder(id, req.IdempotencyKey)));

app.Run();

Điểm đáng lưu ý: chỉ bằng UseDurableOutboxUseDurableInbox, bạn đã có atomic outbox + idempotent inbox chạy trên PostgreSQL mà không cần viết một dòng SQL nào. Projection snapshot inline được cấu hình cho OrderAggregate để tránh phải fold hết toàn bộ stream mỗi lần load, còn hai projection async (OrderSummary, OrderRevenue) chạy trong daemon background, tự động replay khi thêm mới.

9. Schema Evolution, Upcasting và Snapshot - Vấn đề sống còn với stream dài hạn

Event Sourcing có một đặc tính oái oăm: event đã append thì không bao giờ được sửa. Quy tắc này không phải là một nguyên lý triết học, mà là hệ quả trực tiếp của việc lịch sử phải immutable để projection có thể rebuild đúng. Nhưng code thì tiến hóa - bạn phải thêm field vào event, đổi tên, tách một event thành hai, gộp hai thành một. Làm thế nào để tiến hóa mà không phá vỡ stream cũ?

Câu trả lời là upcasting: một cơ chế chuyển đổi runtime từ event version cũ sang event version mới khi đọc. Khi projection handler đọc một event, framework sẽ phát hiện version và chạy chain upcaster để biến đổi nó thành cấu trúc hiện tại trước khi pass cho handler. Với Marten 7, upcaster được khai báo như sau:

public class OrderPlacedUpcaster : EventUpcaster<OrderPlacedV1, OrderPlaced>
{
    protected override OrderPlaced Upcast(OrderPlacedV1 old) =>
        new OrderPlaced(
            OrderId: old.OrderId,
            CustomerId: old.CustomerId,
            OccurredAt: old.CreatedAt,
            Channel: "WEB" // giá trị mặc định cho field mới
        );
}

Chiến lược upcasting thường đi kèm với một quy tắc: mọi event mới đều phải có version, ngay cả phiên bản đầu tiên. Cái giá phải trả cho quy tắc này rất thấp (một int trong payload), nhưng nó cứu bạn khỏi rất nhiều phiền toái về sau. Ngoài ra, một số đội áp dụng chiến lược weak schema: event được serialize dưới dạng JSON với các field optional, và deserializer bỏ qua field lạ. Cách này đơn giản nhưng chỉ hoạt động tốt khi thay đổi là additive (thêm field). Với thay đổi breaking (đổi tên, đổi kiểu, tách event), upcaster vẫn là bắt buộc.

Snapshot là một công cụ bổ sung, giải quyết bài toán performance khi stream dài. Thay vì fold từ event 0 tới event 10.000 mỗi lần load aggregate, bạn lưu một snapshot tại event 9.000 và chỉ fold từ snapshot đó trở đi. Marten hỗ trợ inline snapshot (lưu cùng transaction với append) và async snapshot (lưu background theo ngưỡng). Nguyên tắc thực tế: nếu stream điển hình vượt 1.000 event, bật snapshot. Nếu không, bạn đang trả performance cost mà không có lý do.

Nguyên tắc versioning thực dụng

Luôn bắt đầu event với postfix version: OrderPlacedV1, OrderPlacedV2. Khi cần thay đổi, tạo version mới và viết upcaster. Không rename hoặc delete event version cũ - chúng phải tồn tại mãi trong codebase vì stream cũ vẫn chứa chúng. Dọn dẹp chỉ có thể thực hiện sau khi bạn chắc chắn mọi stream đã được migrate bằng rewrite (một thao tác hiếm, chỉ dùng khi thực sự cần thiết).

10. Saga Pattern - Điều phối giao dịch phân tán trên nền event

Một câu hỏi rất nhanh xuất hiện khi bạn triển khai Event Sourcing cho nhiều aggregate: làm thế nào để điều phối một workflow trải dài qua nhiều aggregate, ví dụ "tạo đơn hàng, trừ kho, xử lý thanh toán, giao hàng"? Mỗi bước là một aggregate khác nhau, không thể nằm chung transaction, và bất kỳ bước nào cũng có thể thất bại. Câu trả lời là Saga Pattern: một process manager theo dõi workflow, phản ứng với event từ các aggregate và gửi command để tiến hoặc rollback.

Saga có hai phong cách triển khai. Orchestration saga có một node trung tâm quyết định bước tiếp theo, đơn giản để suy luận và debug, nhưng tạo single point of failure. Choreography saga phân tán: mỗi service phản ứng với event của service khác theo rule đã định nghĩa, không có trung tâm, scale tốt hơn nhưng khó theo dõi luồng tổng thể. Năm 2026, xu hướng rõ rệt là orchestration với một tool bên ngoài như Temporal, vừa giữ được đơn giản của trung tâm vừa tận dụng được durability của Temporal workflow.

stateDiagram-v2
    [*] --> OrderCreated
    OrderCreated --> ReservingStock: OrderConfirmed
    ReservingStock --> StockReserved: StockOk
    ReservingStock --> Cancelled: StockFailed
    StockReserved --> ProcessingPayment
    ProcessingPayment --> PaymentOk: PaymentSucceeded
    ProcessingPayment --> CompensatingStock: PaymentFailed
    CompensatingStock --> Cancelled
    PaymentOk --> Shipping
    Shipping --> Completed: Shipped
    Shipping --> CompensatingPayment: ShipmentFailed
    CompensatingPayment --> CompensatingStock
    Cancelled --> [*]
    Completed --> [*]
Hình 4: Saga đơn hàng điển hình với compensation khi từng bước thất bại

Trên .NET 10, có ba lựa chọn saga chủ đạo:

  1. Wolverine Saga - saga viết thuần C# trong cùng process, state lưu trong PostgreSQL qua Marten, thích hợp cho workflow ngắn (dưới vài phút). Ưu điểm: đơn giản, debug dễ, không thêm hạ tầng.
  2. MassTransit Saga State Machine - sử dụng Automatonymous DSL để mô tả state machine, tích hợp sâu với RabbitMQ/Azure Service Bus/Kafka. Ưu điểm: matured, visual modeling, cộng đồng lớn.
  3. Temporal.io .NET SDK - workflow viết như function C# thông thường nhưng được Temporal đảm bảo durable execution, tự động retry, timer, và version routing. Đây là lựa chọn cho workflow dài (giờ, ngày, tháng) hoặc workflow cần guarantee rất mạnh.

Điểm chung của cả ba: saga không sở hữu state của aggregate. Saga chỉ điều phối bằng cách gửi command tới aggregate và lắng nghe event phản hồi. Nếu bạn thấy saga đang giữ nhiều domain data, đó là tín hiệu model đã sai: state đó thuộc về một aggregate nào đó chưa được định nghĩa rõ.

11. Những cái bẫy production mà các slide không nhắc tới

Sau khi triển khai CQRS + Event Sourcing vào ít nhất ba dự án thực tế, mỗi dự án đều dạy cho team một bài học mà không sách nào đề cập. Dưới đây là danh sách các bẫy phổ biến nhất mà bạn sẽ va vào nếu không chuẩn bị trước.

11.1. Eventual consistency không phải excuse cho UX lỗi

Khi chuyển sang async projection, có một khoảng thời gian ngắn - vài ms tới vài trăm ms - giữa lúc user submit command và lúc read model được cập nhật. Nếu UI redirect thẳng sang trang danh sách và query read model, user sẽ thấy dữ liệu cũ và nghĩ rằng thao tác của mình không thành công. Giải pháp không phải là chuyển projection sang inline (đánh đổi performance), mà là thiết kế UI để phản ánh đúng thực tế: sau khi submit, giữ user trên màn hình confirm với thông tin từ chính command response (không query lại), hoặc dùng polling nhẹ để đợi projection catch up. Wolverine 3.0 có sẵn một helper WaitForProjection trả về Task hoàn thành khi projection đã apply event vừa sinh.

11.2. Idempotency key không phải option

Mọi command public ra bên ngoài đều phải kèm idempotency key. Nếu không, bạn chắc chắn sẽ gặp double-submit từ retry mạng, từ user click đôi lần, từ consumer retry sau timeout. Command handler phải kiểm tra key đã xử lý chưa trước khi thực thi, và trả về kết quả đã lưu nếu có. Chỗ lưu idempotency key thường là một bảng riêng với unique constraint và TTL vài ngày.

11.3. Projection chậm không có nghĩa Event Store chậm

Một bẫy tâm lý phổ biến: khi dashboard load chậm, team đổ lỗi cho Event Store. Thực tế gần như luôn là projection bị nghẽn do một handler query read store với pattern kém, hoặc một join sai. Cách debug đúng: đo latency từng projection riêng qua OpenTelemetry, không đo tổng thể. Marten 7 export sẵn metric marten_projection_lag_seconds cho mỗi projection.

11.4. Stream bloat - không phải mọi thứ đều cần là event

Có một cám dỗ rất lớn khi mới áp dụng Event Sourcing: event hóa mọi thứ, kể cả thay đổi không phải domain event. Ví dụ: "UserLastSeenAtUpdated" xảy ra mỗi lần user active, sinh hàng triệu event mỗi ngày. Đây không phải event, đây là metric. Giải pháp: giữ Event Store cho domain event thực sự, còn metric/telemetry đẩy thẳng sang ClickHouse hoặc Prometheus. Đừng nhầm lẫn hai loại dữ liệu này.

11.5. Snapshot không miễn phí

Snapshot giúp load aggregate nhanh hơn nhưng cũng tạo ra một nguồn bug rất khó bắt: khi bạn thêm field mới vào aggregate nhưng quên reset snapshot, state load ra sẽ thiếu field đó và hành vi sẽ sai một cách im lặng. Quy tắc: mỗi lần thay đổi schema aggregate là một lần cần kế hoạch rebuild snapshot. Marten 7 có cơ chế SnapshotSchemaVersion, tự động invalidate snapshot khi version thay đổi. Nếu custom, hãy tự thêm cơ chế tương tự.

Hai điều không nên làm trong năm đầu

Khi team lần đầu áp dụng Event Sourcing, tránh hai điều: (1) đừng dùng Event Sourcing cho mọi bounded context - chọn một bounded context có nhu cầu audit cao hoặc cần nhiều view, để team có không gian học; (2) đừng tối ưu sớm - bắt đầu với tất cả projection async chạy trên cùng database, và chỉ tách ra khi có bottleneck đo được. Tối ưu trước khi đo là cách chắc chắn nhất để kiến trúc trở nên không thể bảo trì.

12. Tích hợp với Kafka 4.0 - Khi event tích hợp gặp event domain

Một microservice CQRS + Event Sourcing hiếm khi sống một mình. Nó phải giao tiếp với các bounded context khác, và giao tiếp đó thường đi qua một message broker, gần như mặc định là Kafka ở quy mô doanh nghiệp. Câu hỏi quan trọng: event nào publish ra Kafka, event nào giữ riêng trong Event Store?

Nguyên tắc thực dụng: không bao giờ publish raw domain event ra ngoài bounded context. Lý do: domain event phản ánh chi tiết nội bộ của aggregate, bao gồm những field mà consumer bên ngoài không cần biết, không nên phụ thuộc vào, và có thể thay đổi khi model nội bộ tiến hóa. Thay vào đó, publish integration event: một phiên bản được chủ đích thiết kế cho bên ngoài, với schema ổn định, field ít hơn, và versioning rõ ràng.

// Domain event - chỉ tồn tại trong bounded context Order
public record OrderConfirmed(
    Guid OrderId,
    decimal TotalAmount,
    decimal DiscountApplied,
    List<OrderLineSnapshot> Lines,
    string InternalRoutingCode,
    DateTime ConfirmedAt
);

// Integration event - publish ra Kafka cho các service khác
public record OrderConfirmedIntegrationEvent(
    Guid OrderId,
    Guid CustomerId,
    decimal TotalAmount,
    DateTime OccurredAt,
    int SchemaVersion = 1
);

public static class OrderIntegrationMapper
{
    public static OrderConfirmedIntegrationEvent ToIntegration(OrderConfirmed e, Guid customerId) =>
        new(e.OrderId, customerId, e.TotalAmount, e.ConfirmedAt);
}

Trong Wolverine, mapping này được đặt trong một handler trung gian, đọc domain event từ Event Store và ghi integration event ra Outbox. Kafka 4.0 với KRaft đã loại bỏ dependency ZooKeeper, nâng throughput và giảm vận hành đáng kể - điểm này cũng đáng tận dụng nếu bạn đang dùng Kafka 3.x: upgrade không phức tạp, và lợi ích rõ ràng.

13. Kết luận và lộ trình áp dụng thực tế

CQRS và Event Sourcing năm 2026 không còn là lãnh địa của "kiến trúc nâng cao cho hệ thống khổng lồ". Với Wolverine 3.0 và Marten 7, ngưỡng bước vào đã thấp hơn đáng kể, và lợi ích - audit hoàn chỉnh, projection rebuild được, loại bỏ dual write - trở nên thực sự tiếp cận được cho team .NET cỡ trung bình. Nhưng pattern vẫn giữ nguyên đặc tính của nó: một khoản đầu tư ban đầu đáng kể về thiết kế, kỷ luật về schema, và văn hóa engineering quen với tư duy "event-first".

Tuần 1-2
Hiểu pattern và chọn bounded context. Đọc kỹ về Aggregate, Event, Projection. Chọn một bounded context phù hợp (có audit requirement hoặc nhiều view) để thử nghiệm. Tuyệt đối không bắt đầu với bounded context trọng yếu.
Tuần 3-4
Dựng skeleton với Wolverine + Marten. Build một aggregate đơn giản, một command, hai event, một inline projection. Mục tiêu: chạy end-to-end và hiểu flow, chưa quan tâm tới projection async hay saga.
Tuần 5-6
Thêm Outbox và integration event. Bật UseDurableOutbox, publish integration event ra Kafka. Viết một consumer phía khác để xác nhận luồng hoàn chỉnh. Đây là bước chuyển từ "toy" sang "production candidate".
Tuần 7-8
Async projection và idempotency. Chuyển một projection sang async daemon, đảm bảo projection rebuild được từ zero. Thêm idempotency key cho mọi command public. Đo latency projection qua OpenTelemetry.
Tuần 9-10
Saga đơn giản và chaos test. Thêm một saga điều phối hai aggregate. Chạy chaos test: kill broker, kill projection daemon, inject duplicate event. Mục tiêu: team tự tin hệ thống phục hồi đúng.
Tuần 11+
Triển khai production với monitoring đầy đủ. Dashboard projection lag, outbox depth, command latency. Viết runbook cho các kịch bản hỏng hóc phổ biến. Chỉ sau giai đoạn này mới nghĩ tới mở rộng pattern sang bounded context thứ hai.

Điều đáng nhớ nhất sau hành trình này: CQRS + Event Sourcing không phải là một viên đạn bạc, và cũng không phải một công cụ khoa trương cho CV. Chúng là hai pattern trả lời rất tốt một tập câu hỏi cụ thể - audit, projection đa dạng, consistency liên service - và trả lời kém cho các bài toán khác. Áp dụng đúng chỗ, chúng biến kiến trúc thành một hệ thống vừa dễ tiến hóa vừa có độ tin cậy cao mà khó có cách nào khác đạt được. Áp dụng sai chỗ, chúng biến một dự án 6 tháng thành một dự án 18 tháng với team kiệt sức. Sự khác biệt nằm ở việc team có đủ kỷ luật để nói "chưa cần" khi bounded context không thực sự đòi hỏi, và có đủ kiên trì để đi qua giai đoạn học tập khi đã quyết định áp dụng.

Nguồn tham khảo