456 lines
16 KiB
Markdown
456 lines
16 KiB
Markdown
---
|
|
name: event-sourcing
|
|
description: Event Sourcing pattern - lưu trữ thay đổi trạng thái dưới dạng chuỗi sự kiện. Use for audit trails, temporal queries, CQRS integration, và event replay.
|
|
compatibility: ".NET 10+, EventStoreDB, Marten, EF Core"
|
|
metadata:
|
|
author: Velik Ho
|
|
version: "1.0"
|
|
---
|
|
|
|
# Event Sourcing / Event Sourcing Pattern
|
|
|
|
Event Sourcing pattern cho GoodGo microservices - lưu trữ mọi thay đổi trạng thái dưới dạng sự kiện bất biến.
|
|
|
|
## When to Use This Skill / Khi Nào Sử Dụng
|
|
|
|
Use this skill when:
|
|
- Building audit trails / Xây dựng audit trails
|
|
- Implementing temporal queries (time travel) / Truy vấn theo thời gian
|
|
- Debugging production issues / Debug lỗi production
|
|
- CQRS with event-driven projections / CQRS với projections
|
|
- Ensuring data consistency / Đảm bảo tính nhất quán dữ liệu
|
|
|
|
## Core Concepts / Khái Niệm Cốt Lõi
|
|
|
|
### Traditional vs Event Sourcing / Truyền Thống vs Event Sourcing
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ TRADITIONAL (State-Based) │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ Order Table │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ Id: 1, Status: Shipped, Amount: 500, Updated: 10:30 │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ ❌ Lost: Why was it shipped? What was previous status? │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ EVENT SOURCING (Event-Based) │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ Event Stream: Order-1 │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ 1. OrderCreated { Amount: 500, UserId: "u1" } @10:00│ │
|
|
│ │ 2. OrderPaid { PaymentId: "p1" } @10:15 │ │
|
|
│ │ 3. OrderShipped { TrackingNo: "TN123" } @10:30 │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ ✅ Full history preserved │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Key Components / Thành Phần Chính
|
|
|
|
| Component | Purpose | Description |
|
|
|-----------|---------|-------------|
|
|
| **Event** | Immutable fact | Sự kiện đã xảy ra, không thể thay đổi |
|
|
| **Event Stream** | Ordered sequence | Chuỗi events theo thời gian cho mỗi aggregate |
|
|
| **Event Store** | Append-only log | Database lưu trữ events |
|
|
| **Projection** | Read model | View được xây dựng từ events |
|
|
| **Snapshot** | State cache | Cache trạng thái để tối ưu replay |
|
|
|
|
### Event Sourcing Flow / Luồng Event Sourcing
|
|
|
|
```
|
|
┌─────────┐ ┌─────────────┐ ┌─────────────┐
|
|
│ Command │───▶│ Aggregate │───▶│ Events │
|
|
└─────────┘ │ (Domain) │ │ (Facts) │
|
|
└─────────────┘ └──────┬──────┘
|
|
│
|
|
┌─────────────────────────┼─────────────────────────┐
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
│ Event Store │ │ Projection │ │ Snapshot │
|
|
│ (Write) │ │ (Read) │ │ (Cache) │
|
|
└─────────────┘ └─────────────┘ └─────────────┘
|
|
```
|
|
|
|
## Key Patterns / Mẫu Chính
|
|
|
|
### Domain Event Definition / Định Nghĩa Domain Event
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Base interface for all domain events.
|
|
/// VI: Interface cơ sở cho tất cả domain events.
|
|
/// </summary>
|
|
public interface IDomainEvent
|
|
{
|
|
Guid EventId { get; }
|
|
DateTime OccurredOn { get; }
|
|
int Version { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Order created event.
|
|
/// VI: Event tạo order.
|
|
/// </summary>
|
|
public record OrderCreated : IDomainEvent
|
|
{
|
|
public Guid EventId { get; init; } = Guid.NewGuid();
|
|
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
|
|
public int Version { get; init; }
|
|
|
|
public Guid OrderId { get; init; }
|
|
public string UserId { get; init; } = default!;
|
|
public decimal TotalAmount { get; init; }
|
|
public Address ShippingAddress { get; init; } = default!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Order item added event.
|
|
/// VI: Event thêm item vào order.
|
|
/// </summary>
|
|
public record OrderItemAdded : IDomainEvent
|
|
{
|
|
public Guid EventId { get; init; } = Guid.NewGuid();
|
|
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
|
|
public int Version { get; init; }
|
|
|
|
public Guid OrderId { get; init; }
|
|
public Guid ProductId { get; init; }
|
|
public int Quantity { get; init; }
|
|
public decimal UnitPrice { get; init; }
|
|
}
|
|
```
|
|
|
|
### Event-Sourced Aggregate / Aggregate Event-Sourced
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Base class for event-sourced aggregates.
|
|
/// VI: Lớp cơ sở cho aggregate event-sourced.
|
|
/// </summary>
|
|
public abstract class EventSourcedAggregate
|
|
{
|
|
private readonly List<IDomainEvent> _uncommittedEvents = new();
|
|
|
|
public Guid Id { get; protected set; }
|
|
public int Version { get; protected set; } = -1;
|
|
|
|
public IReadOnlyList<IDomainEvent> UncommittedEvents => _uncommittedEvents.AsReadOnly();
|
|
|
|
protected void Apply(IDomainEvent @event)
|
|
{
|
|
When(@event);
|
|
_uncommittedEvents.Add(@event);
|
|
Version++;
|
|
}
|
|
|
|
protected abstract void When(IDomainEvent @event);
|
|
|
|
public void Load(IEnumerable<IDomainEvent> history)
|
|
{
|
|
foreach (var @event in history)
|
|
{
|
|
When(@event);
|
|
Version++;
|
|
}
|
|
}
|
|
|
|
public void ClearUncommittedEvents() => _uncommittedEvents.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Order aggregate with event sourcing.
|
|
/// VI: Order aggregate với event sourcing.
|
|
/// </summary>
|
|
public class Order : EventSourcedAggregate
|
|
{
|
|
public string UserId { get; private set; } = default!;
|
|
public OrderStatus Status { get; private set; }
|
|
public decimal TotalAmount { get; private set; }
|
|
private readonly List<OrderItem> _items = new();
|
|
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
|
|
|
|
// EN: For rehydration / VI: Để khôi phục từ events
|
|
private Order() { }
|
|
|
|
// EN: Factory method for creation / VI: Factory method để tạo mới
|
|
public static Order Create(Guid orderId, string userId, Address shippingAddress)
|
|
{
|
|
var order = new Order();
|
|
order.Apply(new OrderCreated
|
|
{
|
|
OrderId = orderId,
|
|
UserId = userId,
|
|
ShippingAddress = shippingAddress,
|
|
Version = 0
|
|
});
|
|
return order;
|
|
}
|
|
|
|
public void AddItem(Guid productId, int quantity, decimal unitPrice)
|
|
{
|
|
Apply(new OrderItemAdded
|
|
{
|
|
OrderId = Id,
|
|
ProductId = productId,
|
|
Quantity = quantity,
|
|
UnitPrice = unitPrice,
|
|
Version = Version + 1
|
|
});
|
|
}
|
|
|
|
protected override void When(IDomainEvent @event)
|
|
{
|
|
switch (@event)
|
|
{
|
|
case OrderCreated e:
|
|
Id = e.OrderId;
|
|
UserId = e.UserId;
|
|
Status = OrderStatus.Created;
|
|
break;
|
|
|
|
case OrderItemAdded e:
|
|
_items.Add(new OrderItem(e.ProductId, e.Quantity, e.UnitPrice));
|
|
TotalAmount += e.Quantity * e.UnitPrice;
|
|
break;
|
|
|
|
case OrderPaid:
|
|
Status = OrderStatus.Paid;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Event Store Repository / Repository Event Store
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Repository for event-sourced aggregates.
|
|
/// VI: Repository cho aggregate event-sourced.
|
|
/// </summary>
|
|
public interface IEventSourcedRepository<T> where T : EventSourcedAggregate
|
|
{
|
|
Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
|
Task SaveAsync(T aggregate, CancellationToken ct = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: EF Core implementation of event store.
|
|
/// VI: Event store triển khai với EF Core.
|
|
/// </summary>
|
|
public class EfCoreEventStore<T> : IEventSourcedRepository<T>
|
|
where T : EventSourcedAggregate, new()
|
|
{
|
|
private readonly EventStoreDbContext _context;
|
|
private readonly ILogger<EfCoreEventStore<T>> _logger;
|
|
|
|
public EfCoreEventStore(
|
|
EventStoreDbContext context,
|
|
ILogger<EfCoreEventStore<T>> logger)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
var streamName = $"{typeof(T).Name}-{id}";
|
|
|
|
var events = await _context.Events
|
|
.Where(e => e.StreamId == streamName)
|
|
.OrderBy(e => e.Version)
|
|
.ToListAsync(ct);
|
|
|
|
if (!events.Any())
|
|
return null;
|
|
|
|
var aggregate = new T();
|
|
aggregate.Load(events.Select(e => DeserializeEvent(e)));
|
|
|
|
return aggregate;
|
|
}
|
|
|
|
public async Task SaveAsync(T aggregate, CancellationToken ct = default)
|
|
{
|
|
var streamName = $"{typeof(T).Name}-{aggregate.Id}";
|
|
var expectedVersion = aggregate.Version - aggregate.UncommittedEvents.Count;
|
|
|
|
// EN: Optimistic concurrency check
|
|
// VI: Kiểm tra optimistic concurrency
|
|
var currentVersion = await _context.Events
|
|
.Where(e => e.StreamId == streamName)
|
|
.MaxAsync(e => (int?)e.Version, ct) ?? -1;
|
|
|
|
if (currentVersion != expectedVersion)
|
|
throw new ConcurrencyException(
|
|
$"Expected version {expectedVersion} but found {currentVersion}");
|
|
|
|
foreach (var @event in aggregate.UncommittedEvents)
|
|
{
|
|
_context.Events.Add(new StoredEvent
|
|
{
|
|
Id = @event.EventId,
|
|
StreamId = streamName,
|
|
EventType = @event.GetType().AssemblyQualifiedName!,
|
|
Data = JsonSerializer.Serialize(@event, @event.GetType()),
|
|
Version = @event.Version,
|
|
OccurredOn = @event.OccurredOn
|
|
});
|
|
}
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
aggregate.ClearUncommittedEvents();
|
|
|
|
_logger.LogInformation(
|
|
"Saved {Count} events to stream {Stream}",
|
|
aggregate.UncommittedEvents.Count,
|
|
streamName);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Projections / Projections
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Projection handler for order read models.
|
|
/// VI: Projection handler cho order read models.
|
|
/// </summary>
|
|
public class OrderProjection : IEventHandler<OrderCreated>, IEventHandler<OrderPaid>
|
|
{
|
|
private readonly ReadDbContext _readDb;
|
|
|
|
public OrderProjection(ReadDbContext readDb)
|
|
{
|
|
_readDb = readDb;
|
|
}
|
|
|
|
public async Task HandleAsync(OrderCreated @event, CancellationToken ct)
|
|
{
|
|
var readModel = new OrderReadModel
|
|
{
|
|
Id = @event.OrderId,
|
|
UserId = @event.UserId,
|
|
Status = "Created",
|
|
TotalAmount = @event.TotalAmount,
|
|
CreatedAt = @event.OccurredOn
|
|
};
|
|
|
|
_readDb.Orders.Add(readModel);
|
|
await _readDb.SaveChangesAsync(ct);
|
|
}
|
|
|
|
public async Task HandleAsync(OrderPaid @event, CancellationToken ct)
|
|
{
|
|
var order = await _readDb.Orders.FindAsync(@event.OrderId);
|
|
if (order != null)
|
|
{
|
|
order.Status = "Paid";
|
|
order.PaidAt = @event.OccurredOn;
|
|
await _readDb.SaveChangesAsync(ct);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Common Mistakes / Lỗi Thường Gặp
|
|
|
|
### 1. Mutable Events
|
|
|
|
```csharp
|
|
// ❌ BAD: Mutable event
|
|
public class OrderCreated
|
|
{
|
|
public string Status { get; set; } // Mutable!
|
|
}
|
|
|
|
// ✅ GOOD: Immutable record
|
|
public record OrderCreated
|
|
{
|
|
public string Status { get; init; } // Immutable
|
|
}
|
|
```
|
|
|
|
### 2. Storing Derived Data in Events
|
|
|
|
```csharp
|
|
// ❌ BAD: Storing computed values
|
|
public record OrderItemAdded
|
|
{
|
|
public decimal UnitPrice { get; init; }
|
|
public int Quantity { get; init; }
|
|
public decimal TotalPrice { get; init; } // Derived!
|
|
}
|
|
|
|
// ✅ GOOD: Store only facts, compute when needed
|
|
public record OrderItemAdded
|
|
{
|
|
public decimal UnitPrice { get; init; }
|
|
public int Quantity { get; init; }
|
|
// TotalPrice computed in aggregate
|
|
}
|
|
```
|
|
|
|
### 3. Large Event Streams Without Snapshots
|
|
|
|
```csharp
|
|
// ❌ BAD: Loading thousands of events
|
|
var order = await _repo.GetByIdAsync(orderId); // Slow!
|
|
|
|
// ✅ GOOD: Use snapshots for performance
|
|
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct)
|
|
{
|
|
// Load from snapshot if exists
|
|
var snapshot = await _snapshotStore.GetLatestAsync<Order>(id, ct);
|
|
var fromVersion = snapshot?.Version ?? 0;
|
|
|
|
// Load only events after snapshot
|
|
var events = await _eventStore.GetEventsAsync(id, fromVersion, ct);
|
|
|
|
var aggregate = snapshot ?? new Order();
|
|
aggregate.Load(events);
|
|
return aggregate;
|
|
}
|
|
```
|
|
|
|
## Quick Reference / Tham Chiếu Nhanh
|
|
|
|
### When to Use Event Sourcing
|
|
|
|
| Scenario | Recommendation |
|
|
|----------|---------------|
|
|
| Simple CRUD | ❌ Overkill |
|
|
| Complex domains | ✅ Use ES |
|
|
| Audit requirements | ✅ Use ES |
|
|
| Temporal queries | ✅ Use ES |
|
|
| High write volume | ⚠️ Consider carefully |
|
|
|
|
### Event Naming Conventions
|
|
|
|
| Type | Convention | Example |
|
|
|------|-----------|---------|
|
|
| Create | `{Entity}Created` | `OrderCreated` |
|
|
| Update | `{Entity}{Property}Changed` | `OrderStatusChanged` |
|
|
| Delete | `{Entity}Deleted` | `OrderDeleted` |
|
|
| Action | `{Entity}{Action}` | `OrderShipped` |
|
|
|
|
### Snapshot Strategy
|
|
|
|
| Trigger | When to Snapshot |
|
|
|---------|-----------------|
|
|
| Version-based | Every N events (e.g., 100) |
|
|
| Time-based | Every N minutes |
|
|
| Size-based | When aggregate size > threshold |
|
|
|
|
## Resources / Tài Nguyên
|
|
|
|
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
|
|
- [CQRS MediatR](../cqrs-mediatr/SKILL.md) - CQRS patterns
|
|
- [Outbox Pattern](../outbox-pattern/SKILL.md) - Reliable event publishing
|
|
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access patterns
|
|
- [Domain Driven Design](../domain-driven-design/SKILL.md) - DDD patterns
|