CQRS và Event Sourcing — Khi CRUD không còn đủ

Posted on: 4/21/2026 9:12:00 PM

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 consistencyinvariant — 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

100% Audit Trail — mọi thay đổi đều có bằng chứng
Time Travel — replay về bất kỳ thời điểm nào
0 Data Loss — không bao giờ mất thông tin do UPDATE
N+1 Read Models — tạo bao nhiêu projection cũng được từ cùng events

Đị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íEventStoreDBMarten (PostgreSQL)SQL Server + Custom
LoạiPurpose-built event storeDocument DB + Event Store trên PostgreSQLTự xây trên SQL Server
Stream subscriptionBuilt-in (catch-up, persistent)Built-in (async daemon)Tự implement (polling/CDC)
ProjectionBuilt-in JavaScript projectionsBuilt-in .NET projectionsTự implement
ConcurrencyOptimistic (stream version)Optimistic (stream version)Tự implement
Phù hợpHệ thống lớn, event-native.NET ecosystem, PostgreSQL sẵn cóKhi bắt buộc dùng SQL Server
Learning curveTrung bìnhThấ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ợpKhô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 ghiPrototype, MVP giai đoạn đầu
Microservices cần event-driven integrationDữ 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:

1 Optimistic UI — Frontend cập nhật UI ngay khi gửi command, không đợi read model
2 Polling — Frontend poll read model cho đến khi version >= expected version
3 WebSocket — Server push notification khi projection đã cập nhật xong

So sánh CRUD vs CQRS vs CQRS + Event Sourcing

Tiêu chíCRUDCQRSCQRS + Event Sourcing
Lưu trữTrạng thái hiện tạiTrạng thái hiện tại (2 DB)Chuỗi events bất biến
Audit trailKhông (trừ khi thêm audit table)Không tự độngCó sẵn — mọi thay đổi đều là event
Scale đọc/ghiGắn chặt nhauScale độc lậpScale độc lập + append-only write
ComplexityThấpTrung bìnhCao
Time travelKhôngKhôngCó — replay events đến bất kỳ thời điểm
Phù hợpCRUD apps, MVPHệ thống read-heavyHệ thống tài chính, collaborative, event-driven

Checklist triển khai CQRS + Event Sourcing

Bước 1: Domain Modeling
Xác định Aggregate Boundaries, định nghĩa Domain Events theo ngôn ngữ nghiệp vụ (Ubiquitous Language). Events phải thể hiện intent, không phải CRUD operation.
Bước 2: Chọn Event Store
Marten (PostgreSQL) cho .NET ecosystem, EventStoreDB cho hệ thống event-native lớn, hoặc custom trên SQL Server nếu bắt buộc. Đừng tự xây event store từ đầu nếu không cần thiết.
Bước 3: Command Handlers
Implement command validation, load aggregate từ events, execute business logic, append new events. Đảm bảo optimistic concurrency bằng stream version.
Bước 4: Projections
Thiết kế read models theo từng use case UI cụ thể. Inline projection cho consistency, async projection cho report/dashboard. Một event có thể feed nhiều projection.
Bước 5: Event Versioning Strategy
Quyết định dùng upcasting hay weak schema. Có convention rõ ràng cho đội khi thêm field mới vào event. Tuyệt đối không sửa event đã lưu.
Bước 6: Testing & Monitoring
Test aggregate behavior bằng Given-When-Then: given (events trước đó), when (command), then (events mới). Monitor projection lag để phát hiện eventual consistency issues.

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