Skip to content

Advanced Patterns — CQRS, Event Sourcing và DDD

Năm 2019, một ngân hàng số tại Đông Nam Á triển khai hệ thống giao dịch mới. Sau 6 tháng, họ phát hiện một vấn đề nghiêm trọng: khi khách hàng khiếu nại "tôi không thực hiện giao dịch này", team chỉ có dữ liệu cuối cùng — số dư hiện tại — mà không thể truy vết chuỗi sự kiện dẫn đến trạng thái đó. Audit trail bị mất vì hệ thống chỉ lưu current state, ghi đè mọi thay đổi. Họ phải rebuild toàn bộ transaction history từ logs — mất 3 tháng và hàng triệu đô la.

Event Sourcing giải quyết chính xác vấn đề này: thay vì lưu "số dư là $500", hệ thống lưu toàn bộ chuỗi events: "Deposit $1000 → Withdraw $200 → Transfer $300". Bất kỳ lúc nào cũng có thể replay events để reconstruct state tại bất kỳ thời điểm nào. Nhưng Event Sourcing không đi một mình — nó thường kết hợp với CQRS (tách read/write models) và DDD (mô hình hóa domain phức tạp).

Ba patterns này — CQRS, Event Sourcing, DDD — tạo thành bộ công cụ mạnh nhất cho những hệ thống có business logic phức tạp. Nhưng cũng là những patterns bị lạm dụng nhiều nhất. Bài này giúp bạn hiểu khi nào chúng thực sự cần thiết, không chỉ cách implement.

Bức tranh tư duy

Hãy tưởng tượng bạn quản lý một kho hàng lớn.

CRUD truyền thống giống như bạn chỉ có một bảng kiểm kê duy nhất. Mỗi lần nhập/xuất hàng, bạn xóa số cũ và ghi số mới. Nhanh, đơn giản, nhưng nếu ai hỏi "Tại sao kho thiếu 50 thùng?", bạn không có câu trả lời — chỉ biết số hiện tại.

Event Sourcing giống như bạn giữ lại mọi phiếu nhập/xuất từ ngày đầu. Bảng kiểm kê hiện tại chỉ là kết quả tính tổng từ tất cả phiếu. Bất kỳ lúc nào, bạn có thể lật lại phiếu để biết chính xác chuyện gì đã xảy ra, khi nào, do ai thực hiện.

CQRS giống như bạn tách biệt: phiếu nhập/xuất (write) do một team xử lý, còn báo cáo tồn kho (read) do team khác chuẩn bị sẵn dưới nhiều dạng — báo cáo theo ngày, theo nhà cung cấp, theo loại hàng — mỗi dạng tối ưu cho một nhu cầu đọc khác nhau.

DDD là cách bạn tổ chức toàn bộ kho: chia thành các "khu vực" (Bounded Contexts) — khu thực phẩm, khu điện tử, khu hóa chất — mỗi khu có quy trình, nhân viên, và ngôn ngữ riêng. "Hàng tồn" trong khu thực phẩm tính theo ngày hết hạn, trong khu điện tử tính theo serial number.

Giới hạn phép so sánh: Kho hàng vật lý không có "replay" — hàng đã hỏng không thể undo. Trong phần mềm, events có thể replay nhưng side effects (gửi email, charge thẻ) thì không.

Cốt lõi kỹ thuật

CQRS — Command Query Responsibility Segregation

CQRS tách biệt hoàn toàn write model (Commands — thay đổi state) và read model (Queries — đọc state). Hai model có thể dùng schemas khác nhau, databases khác nhau, thậm chí languages khác nhau.

Tại sao tách?

  • Read patterns và write patterns khác nhau fundamentally. Write cần validation, business rules, consistency. Read cần speed, denormalization, multiple projections.
  • Hệ thống e-commerce: 95% traffic là read (browse products), 5% là write (place order). Scale read và write độc lập.
  • Write model tối ưu cho consistency (normalized). Read model tối ưu cho performance (denormalized, pre-computed).

Mức độ CQRS:

  • Level 1: Cùng database, tách code path read/write (đơn giản nhất)
  • Level 2: Read replica — write vào primary, read từ replica
  • Level 3: Databases khác nhau — write vào PostgreSQL, read từ Elasticsearch/Redis
  • Level 4: Full CQRS + Event Sourcing — write là event stream, read là materialized views

Event Sourcing — Lưu sự kiện, không lưu trạng thái

Thay vì lưu current state (CRUD: UPDATE account SET balance = 500), Event Sourcing lưu mọi event đã xảy ra:

Event Store cho Account #12345:
┌────────────┬──────────────────────┬─────────┬───────────────────┐
│ Sequence   │ Event Type           │ Amount  │ Timestamp         │
├────────────┼──────────────────────┼─────────┼───────────────────┤
│ 1          │ AccountOpened         │ $0      │ 2024-01-01 09:00  │
│ 2          │ MoneyDeposited        │ +$1000  │ 2024-01-02 10:30  │
│ 3          │ MoneyWithdrawn        │ -$200   │ 2024-01-03 14:15  │
│ 4          │ MoneyTransferred      │ -$300   │ 2024-01-05 11:00  │
│ Current    │ (replay all events)   │ = $500  │                   │
└────────────┴──────────────────────┴─────────┴───────────────────┘

Đặc điểm:

  • Immutable: Events không bao giờ bị xóa hay sửa — chỉ append-only
  • Time Travel: Replay events đến bất kỳ thời điểm nào → reconstruct past state
  • Complete Audit Trail: Mọi thay đổi đều được ghi lại — ai, khi nào, tại sao
  • Event Replay: Thêm projection mới? Replay toàn bộ events để build read model mới

Snapshots — Tối ưu hiệu năng: Account có 1 triệu events → replay từ đầu mất 30 giây. Giải pháp: lưu snapshot (current state) mỗi N events (thường 100-1000). Replay chỉ cần: load snapshot gần nhất + replay events sau snapshot.

DDD Tactical Patterns — Mô hình hóa domain phức tạp

Domain-Driven Design cung cấp vocabulary và patterns để model business domain chính xác trong code.

Bounded Context: Ranh giới mà trong đó một thuật ngữ có ý nghĩa duy nhất. "Product" trong Catalog context là {name, description, price}. "Product" trong Shipping context là {weight, dimensions, warehouse_location}. Hai contexts, hai models khác nhau cho cùng một khái niệm.

Aggregate: Cluster of entities được treat as a single unit cho data consistency. Order aggregate bao gồm Order + OrderLines + ShippingAddress. Tất cả thay đổi trong aggregate đi qua Aggregate Root (Order). External code không được modify OrderLine trực tiếp.

Quy tắc Aggregate:

  1. Aggregates reference nhau chỉ bằng ID, không bằng object reference
  2. Một transaction chỉ modify một aggregate
  3. Aggregate boundaries = consistency boundaries
  4. Aggregate nhỏ → tốt hơn. Ưu tiên performance và concurrency

Value Objects: Immutable objects được define bởi attributes, không có identity. Money(100, "VND") == Money(100, "VND"). Email("a@b.com") validates format khi tạo. Value Objects encode business rules trong type system.

Domain Events: Sự kiện đã xảy ra trong domain — quá khứ, immutable. OrderPlaced, PaymentReceived, ItemShipped. Domain events là cầu nối giữa bounded contexts.

Hexagonal Architecture — Ports and Adapters

Hexagonal Architecture (còn gọi là Ports and Adapters) đặt domain logic ở trung tâm, bao quanh bởi ports (interface) và adapters (implementation). Domain không phụ thuộc vào framework, database, hay external services.

Dependency Rule: Dependencies luôn hướng vào trong (từ adapters → ports → domain). Domain KHÔNG import bất kỳ infrastructure code nào. Điều này cho phép thay đổi database (PostgreSQL → MongoDB) mà không sửa domain logic.

Testability: Domain logic test bằng unit tests thuần — không cần database, không cần HTTP server, không cần message broker. Adapters test bằng integration tests.

Thực chiến

Tình huống: Hệ thống Insurance Claims — Complex Domain Modeling

Bối cảnh: Công ty bảo hiểm cần xây hệ thống quản lý claims. Business rules phức tạp: mỗi loại bảo hiểm (sức khỏe, xe, nhà) có quy trình xử lý khác nhau, policy rules thay đổi theo thời gian, cần audit trail hoàn chỉnh cho compliance.

Mục tiêu: Xử lý 10.000 claims/ngày, audit trail 100%, rule changes không cần redeploy.

Architecture — DDD + Event Sourcing + CQRS:

Bounded Contexts:
├── Policy Context (quản lý hợp đồng bảo hiểm)
├── Claims Context (xử lý yêu cầu bồi thường)
├── Payment Context (chi trả bồi thường)
└── Compliance Context (audit, reporting)

Claims Aggregate (Event Sourced):
├── ClaimSubmitted {claimId, policyId, amount, documents}
├── ClaimAssigned {adjusterId, priority}
├── ClaimAssessed {assessedAmount, findings}
├── ClaimApproved {approvedAmount, conditions}
│   OR ClaimDenied {reason, appealDeadline}
├── PaymentInitiated {paymentId, amount}
└── ClaimClosed {resolution, totalPaid}

Read Models (CQRS projections):
├── ActiveClaimsDashboard (cho adjusters)
├── ClaimHistoryView (cho customers)
├── FraudDetectionView (cho compliance team)
└── FinancialReportView (cho management)

Kết quả đo được (ước lượng):

  • Event Store size: ~2GB/năm (10.000 claims × 6 events/claim × 365 ngày)
  • Audit query response: < 100ms (projection đã sẵn sàng)
  • Rule changes: deploy new projection mà không ảnh hưởng write path
  • Fraud detection: replay events với rules mới, phát hiện patterns trước đây bỏ sót

Tình huống: E-commerce Product Catalog — CQRS Level 3

Bối cảnh: Catalog 500.000 products, 50 triệu page views/ngày, search phức tạp (filter by category, price range, attributes, full-text search). Write: merchants cập nhật product info, 5.000 updates/ngày.

Mục tiêu: Search response < 50ms P95, write consistency < 1s.

CQRS Level 3 Implementation:

Write Side:
├── Product Service (Go)
├── PostgreSQL (normalized schema)
├── Validates: price > 0, category exists, images uploaded
└── Publishes: ProductUpdated event via Outbox Pattern

Read Side (3 projections):
├── Elasticsearch (full-text search, faceted filters)
├── Redis (product detail cache, TTL: 5 min)
└── Materialized View in PostgreSQL (merchant dashboard)

Sync: CDC (Debezium) → Kafka → Projection consumers
Eventual consistency lag: 100-500ms

Phân tích: Write path đơn giản (normalized PostgreSQL), read path tối ưu cho từng use case. Search dùng Elasticsearch, product detail dùng Redis cache, merchant dashboard dùng materialized view. Trade-off: eventual consistency 100-500ms — chấp nhận được cho catalog (không phải payment).

Sai lầm điển hình

Sai lầm 1: Áp dụng Event Sourcing cho mọi service

Vấn đề: Team quyết định event-source tất cả services bao gồm User Profile, Notification Settings — những domain đơn giản.

Tại sao sai: Event Sourcing thêm complexity đáng kể: event schema evolution, snapshot management, projection rebuilds. User Profile chỉ cần CRUD — tên, email, avatar. Event sourcing cho domain này là over-engineering: chi phí complexity lớn hơn nhiều lần giá trị audit trail mang lại.

ĐÚNG: Event Sourcing chỉ cho domains cần audit trail (financial, compliance), cần time travel (analytics, debugging), hoặc business logic phức tạp (order processing, insurance claims). Phần còn lại: CRUD là đủ.

Sai lầm 2: Aggregate quá lớn

Vấn đề: Order Aggregate bao gồm Order + Customer + Products + Reviews + ShippingHistory.

// SAI: Aggregate quá lớn — lock toàn bộ khi thay đổi bất kỳ phần nào
class OrderAggregate {
    Order order;
    Customer customer;          // Thuộc Customer Aggregate!
    List<Product> products;     // Thuộc Catalog Aggregate!
    List<Review> reviews;       // Thuộc Review Aggregate!
    ShippingHistory history;    // Thuộc Shipping Aggregate!
}

Tại sao sai: Aggregate lớn = lock lớn = concurrency thấp. Thay đổi customer email lock toàn bộ order. Optimistic concurrency conflicts tăng exponentially với aggregate size.

// ĐÚNG: Aggregate nhỏ, reference bằng ID
class OrderAggregate {
    OrderId id;
    CustomerId customerId;       // Reference bằng ID
    List<OrderLine> lines;       // OrderLine thuộc Order
    ShippingAddress address;     // Value Object, copy vào Order
    Money totalAmount;           // Value Object
}

Sai lầm 3: CQRS cho CRUD đơn giản

Vấn đề: Áp dụng full CQRS (separate databases) cho blog CMS với 100 bài viết.

Tại sao sai: CQRS thêm eventual consistency complexity, infrastructure cost (multiple databases + sync mechanism). Blog CMS: read/write ratio không đủ extreme, business logic đơn giản, 100 records không cần optimize.

ĐÚNG: CQRS khi read/write patterns khác biệt rõ rệt (ratio > 10:1), business logic write phức tạp, hoặc cần multiple read models. Phần lớn ứng dụng CRUD truyền thống là đủ tốt.

Sai lầm 4: Không xử lý Event Schema Evolution

Vấn đề: Thay đổi event schema (thêm field, đổi type) mà không có migration strategy.

Tại sao sai: Event Store là immutable — không thể ALTER events cũ. Consumer đọc event v1 khi code expect v2 → crash. Events tồn tại mãi mãi — schema phải forward-compatible.

ĐÚNG: Versioned events (OrderPlacedV1, OrderPlacedV2) + Upcaster (transform v1 → v2 khi đọc). Hoặc: weak schema (JSON) + backward-compatible changes (chỉ thêm optional fields, không xóa/rename).

Under the Hood

Performance Analysis

ApproachWrite LatencyRead LatencyStorageComplexity
CRUD5-20ms5-20ms1xLow
CQRS Level 15-20ms3-10ms1.2xMedium
CQRS Level 310-30ms1-5ms3-5xHigh
Event Sourcing5-15ms (append)10-50ms (replay)5-20xVery High
ES + Snapshots5-15ms (append)2-10ms (snapshot)6-22xVery High

Event Store Scaling

Storage growth: Event Store tăng vô hạn (immutable, append-only). 10.000 events/ngày × 1KB/event = 3.65GB/năm. Sounds small, nhưng: 1 triệu users × 100 events/user = 100 triệu events. Cần partitioning strategy (thường theo aggregate_id).

Snapshot strategy: Snapshot mỗi 100 events. 1 triệu events → 10.000 snapshots. Replay chỉ cần load 1 snapshot + ≤ 100 events. Giảm replay time từ 30s xuống < 10ms.

Projection rebuild: Thêm read model mới → replay toàn bộ event store. 100 triệu events, throughput 50.000 events/giây → 33 phút rebuild. Cần batch processing infrastructure (Kafka Streams, Flink).

Cost Analysis (ước lượng)

ComponentCRUDCQRS + ES
Primary Database$500/tháng$500/tháng
Event Store$300-1.000/tháng
Read Databases$500-2.000/tháng
CDC/Sync$300-800/tháng
Engineering time1x3-5x (ban đầu)

Trade-offs tổng hợp

PatternDùng khi...Không dùng khi...
CQRSRead/write ratio > 10:1, multiple read patternsCRUD đơn giản, < 1.000 users
Event SourcingCần audit trail, time travel, complex domainSimple CRUD, no compliance needs
DDDDomain phức tạp, nhiều business rulesData-centric apps, simple CRUD
HexagonalCần swap infrastructure, long-lived projectPrototype, script, single-use tool

Checklist ghi nhớ

✅ Checklist triển khai

CQRS Assessment

  • [ ] Xác nhận read/write ratio vượt 10:1
  • [ ] Liệt kê read models cần thiết (search, dashboard, report)
  • [ ] Chọn mức CQRS phù hợp (Level 1-4)
  • [ ] Đánh giá eventual consistency có chấp nhận được cho business

Event Sourcing

  • [ ] Domain có cần audit trail không?
  • [ ] Đã thiết kế event schema versioning strategy
  • [ ] Snapshot policy: mỗi bao nhiêu events?
  • [ ] Event Store partitioning strategy (theo aggregate_id)
  • [ ] Projection rebuild plan (thời gian ước lượng, tooling)

DDD Implementation

  • [ ] Bounded Contexts đã xác định qua Event Storming hoặc domain analysis
  • [ ] Aggregates giữ nhỏ (< 5 entities per aggregate)
  • [ ] Cross-aggregate communication qua Domain Events
  • [ ] Value Objects encode business rules trong type system

Hexagonal Architecture

  • [ ] Domain core không import infrastructure code
  • [ ] Ports defined as interfaces
  • [ ] Domain testable với unit tests thuần (không cần DB/HTTP)

Bài tập luyện tập

Bài 1: Event Storming — Intermediate

Đề bài: Cho hệ thống đặt phòng khách sạn, xác định Domain Events cho booking flow: khách tìm phòng → chọn phòng → đặt → thanh toán → nhận xác nhận → check-in → check-out. Liệt kê ít nhất 8 events và xác định Bounded Contexts.

🧠 Quiz

Câu hỏi kiểm tra: Trong DDD, "Room" trong Booking Context và "Room" trong Housekeeping Context nên được model như thế nào?

  • [ ] A. Cùng một entity class, chia sẻ giữa 2 contexts
  • [x] B. Hai entity class riêng biệt với attributes khác nhau
  • [ ] C. Một entity class abstract, 2 subclasses
  • [ ] D. Không cần model Room trong Housekeeping Context Giải thích: Bounded Context nghĩa là cùng thuật ngữ có ý nghĩa khác nhau. "Room" trong Booking = {roomType, price, availability}. "Room" trong Housekeeping = {cleaningStatus, lastCleaned, supplies}. Chia sẻ entity vi phạm nguyên tắc Bounded Context và tạo coupling giữa hai domains.
✅ Lời giải chi tiết
Domain Events:
1. RoomSearched {checkIn, checkOut, guests, roomType}
2. RoomSelected {roomId, price, dates}
3. BookingCreated {bookingId, customerId, roomId, dates, totalPrice}
4. PaymentProcessed {bookingId, paymentId, amount}
5. BookingConfirmed {bookingId, confirmationCode}
6. GuestCheckedIn {bookingId, actualCheckIn}
7. RoomServiceRequested {roomId, serviceType, items}
8. GuestCheckedOut {bookingId, actualCheckOut, finalBill}
9. RoomCleaningScheduled {roomId, priority}
10. InvoiceGenerated {bookingId, invoiceId, totalAmount}

Bounded Contexts:
├── Booking Context: Events 1-5 (search, select, book, pay, confirm)
├── Stay Context: Events 6-8 (check-in, room service, check-out)
├── Housekeeping Context: Event 9 (cleaning)
└── Billing Context: Event 10 (invoicing)

Bài 2: CQRS Design — Advanced

Đề bài: Thiết kế CQRS architecture cho hệ thống social media feed. Write: user post status, comment, like. Read: timeline feed (chronological + relevance), notification feed, trending posts. Xác định write model, read models, và sync mechanism.

💡 Gợi ý
  • Write model: normalized PostgreSQL (posts, comments, likes tables)
  • Timeline feed: pre-computed per user (fan-out on write vs fan-out on read)
  • Trending: real-time aggregation (sliding window)
✅ Lời giải chi tiết
Write Model (PostgreSQL):
├── posts (id, user_id, content, created_at)
├── comments (id, post_id, user_id, content)
├── likes (post_id, user_id)
└── Publishes: PostCreated, CommentAdded, PostLiked events

Read Models:
├── Timeline Feed (Redis Sorted Set per user)
│   Fan-out on write: khi user A post → push vào timeline
│   của tất cả followers
│   Celebrity exception: fan-out on read (quá nhiều followers)

├── Notification Feed (Redis List per user)
│   Real-time push via WebSocket

└── Trending Posts (Redis Sorted Set, global)
    Sliding window: count likes + comments trong 24h
    Refresh: mỗi 1 phút

Sync: Kafka → Consumer groups per read model
Eventual consistency: 100ms-2s (acceptable cho social media)

Phân tích: Fan-out on write cho users thông thường (< 10.000 followers), fan-out on read cho celebrities (> 100.000 followers). Hybrid approach giống Twitter/X sử dụng.

Bài 3: Hexagonal Architecture Refactoring — Advanced

Đề bài: Refactor đoạn code sau từ "framework-coupled" sang Hexagonal Architecture. Xác định ports, adapters, và domain core.

python
# Code hiện tại — coupled với framework và database
class OrderController:
    def create_order(self, request):
        data = json.loads(request.body)
        if data["amount"] <= 0:
            return HttpResponse(400, "Invalid amount")
        order = Order(amount=data["amount"], user_id=data["user_id"])
        db.session.add(order)
        db.session.commit()
        kafka.publish("order.created", order.to_dict())
        return HttpResponse(201, order.to_dict())

🧠 Quiz

Câu hỏi: Trong Hexagonal Architecture, business rule "amount > 0" nên nằm ở đâu?

  • [ ] A. Controller (adapter layer)
  • [ ] B. Database constraint
  • [x] C. Domain model (Order entity hoặc Value Object Money)
  • [ ] D. Middleware/interceptor Giải thích: Business rules thuộc về Domain Core. Nếu đặt ở controller, rules bị duplicate khi thêm gRPC endpoint. Nếu đặt ở database, domain logic bị leak ra infrastructure. Value Object Money(amount) validate khi khởi tạo — đảm bảo invalid state không tồn tại.
✅ Lời giải chi tiết
python
# Domain Core
class Money:  # Value Object
    def __init__(self, amount: Decimal):
        if amount <= 0:
            raise InvalidMoneyError("Amount must be positive")
        self.amount = amount

class Order:  # Aggregate Root
    def __init__(self, order_id: str, user_id: str, amount: Money):
        self.order_id = order_id
        self.user_id = user_id
        self.amount = amount
        self.events = [OrderCreated(order_id, user_id, amount)]

# Ports (Interfaces)
class OrderRepository(ABC):  # Outbound Port
    def save(self, order: Order) -> None: ...

class EventPublisher(ABC):  # Outbound Port
    def publish(self, events: list) -> None: ...

class CreateOrderUseCase:  # Inbound Port / Application Service
    def __init__(self, repo: OrderRepository, publisher: EventPublisher):
        self.repo = repo
        self.publisher = publisher

    def execute(self, user_id: str, amount: Decimal) -> Order:
        money = Money(amount)  # Validates in domain
        order = Order(generate_id(), user_id, money)
        self.repo.save(order)
        self.publisher.publish(order.events)
        return order

# Adapters
class PostgresOrderRepository(OrderRepository):  # Outbound Adapter
    def save(self, order): ...

class KafkaEventPublisher(EventPublisher):  # Outbound Adapter
    def publish(self, events): ...

class OrderHttpController:  # Inbound Adapter
    def __init__(self, use_case: CreateOrderUseCase):
        self.use_case = use_case

    def create_order(self, request):
        data = json.loads(request.body)
        order = self.use_case.execute(data["user_id"], Decimal(data["amount"]))
        return HttpResponse(201, order.to_dict())

Domain Core testable thuần: Money(Decimal("-5")) → raises InvalidMoneyError. Không cần HTTP server hay database.

Liên kết học tiếp

Từ khóa glossary: CQRS, event sourcing, domain-driven design, DDD, aggregate, bounded context, value object, domain event, hexagonal architecture, ports and adapters, event store, projection, snapshot, upcaster

Tìm kiếm liên quan: CQRS là gì, event sourcing implementation, DDD tactical patterns, hexagonal architecture Go, bounded context design