526 lines
15 KiB
Markdown
526 lines
15 KiB
Markdown
# Event Sourcing - Reference Examples
|
|
|
|
## Complete Implementation Examples
|
|
|
|
### 1. Event Store DbContext
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: DbContext for storing events.
|
|
/// VI: DbContext để lưu events.
|
|
/// </summary>
|
|
public class EventStoreDbContext : DbContext
|
|
{
|
|
public DbSet<StoredEvent> Events => Set<StoredEvent>();
|
|
public DbSet<Snapshot> Snapshots => Set<Snapshot>();
|
|
|
|
public EventStoreDbContext(DbContextOptions<EventStoreDbContext> options)
|
|
: base(options) { }
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.Entity<StoredEvent>(entity =>
|
|
{
|
|
entity.ToTable("EventStore");
|
|
entity.HasKey(e => e.Id);
|
|
|
|
entity.Property(e => e.StreamId)
|
|
.HasMaxLength(200)
|
|
.IsRequired();
|
|
|
|
entity.Property(e => e.EventType)
|
|
.HasMaxLength(500)
|
|
.IsRequired();
|
|
|
|
entity.Property(e => e.Data)
|
|
.HasColumnType("jsonb")
|
|
.IsRequired();
|
|
|
|
entity.HasIndex(e => new { e.StreamId, e.Version })
|
|
.IsUnique();
|
|
});
|
|
|
|
modelBuilder.Entity<Snapshot>(entity =>
|
|
{
|
|
entity.ToTable("Snapshots");
|
|
entity.HasKey(e => e.Id);
|
|
|
|
entity.Property(e => e.AggregateId)
|
|
.IsRequired();
|
|
|
|
entity.Property(e => e.Data)
|
|
.HasColumnType("jsonb")
|
|
.IsRequired();
|
|
|
|
entity.HasIndex(e => new { e.AggregateType, e.AggregateId, e.Version })
|
|
.IsUnique();
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Stored event entity.
|
|
/// VI: Entity lưu event.
|
|
/// </summary>
|
|
public class StoredEvent
|
|
{
|
|
public Guid Id { get; set; }
|
|
public string StreamId { get; set; } = default!;
|
|
public string EventType { get; set; } = default!;
|
|
public string Data { get; set; } = default!;
|
|
public int Version { get; set; }
|
|
public DateTime OccurredOn { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Snapshot entity.
|
|
/// VI: Entity snapshot.
|
|
/// </summary>
|
|
public class Snapshot
|
|
{
|
|
public Guid Id { get; set; }
|
|
public string AggregateType { get; set; } = default!;
|
|
public Guid AggregateId { get; set; }
|
|
public string Data { get; set; } = default!;
|
|
public int Version { get; set; }
|
|
public DateTime CreatedAt { get; set; }
|
|
}
|
|
```
|
|
|
|
### 2. Complete Order Aggregate
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Complete order aggregate with all events.
|
|
/// VI: Order aggregate hoàn chỉnh với tất cả events.
|
|
/// </summary>
|
|
public class Order : EventSourcedAggregate
|
|
{
|
|
public string UserId { get; private set; } = default!;
|
|
public OrderStatus Status { get; private set; }
|
|
public decimal TotalAmount { get; private set; }
|
|
public Address? ShippingAddress { get; private set; }
|
|
public string? PaymentId { get; private set; }
|
|
public string? TrackingNumber { get; private set; }
|
|
|
|
private readonly List<OrderItem> _items = new();
|
|
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
|
|
|
|
private Order() { }
|
|
|
|
public static Order Create(Guid orderId, string userId, Address shippingAddress)
|
|
{
|
|
if (string.IsNullOrEmpty(userId))
|
|
throw new ArgumentException("UserId is required");
|
|
|
|
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)
|
|
{
|
|
if (Status != OrderStatus.Created)
|
|
throw new InvalidOperationException("Cannot add items to non-draft order");
|
|
|
|
if (quantity <= 0)
|
|
throw new ArgumentException("Quantity must be positive");
|
|
|
|
Apply(new OrderItemAdded
|
|
{
|
|
OrderId = Id,
|
|
ProductId = productId,
|
|
Quantity = quantity,
|
|
UnitPrice = unitPrice,
|
|
Version = Version + 1
|
|
});
|
|
}
|
|
|
|
public void MarkAsPaid(string paymentId)
|
|
{
|
|
if (Status != OrderStatus.Created)
|
|
throw new InvalidOperationException("Order is not in Created status");
|
|
|
|
Apply(new OrderPaid
|
|
{
|
|
OrderId = Id,
|
|
PaymentId = paymentId,
|
|
Version = Version + 1
|
|
});
|
|
}
|
|
|
|
public void Ship(string trackingNumber)
|
|
{
|
|
if (Status != OrderStatus.Paid)
|
|
throw new InvalidOperationException("Order must be paid before shipping");
|
|
|
|
Apply(new OrderShipped
|
|
{
|
|
OrderId = Id,
|
|
TrackingNumber = trackingNumber,
|
|
Version = Version + 1
|
|
});
|
|
}
|
|
|
|
public void Cancel(string reason)
|
|
{
|
|
if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
|
|
throw new InvalidOperationException("Cannot cancel shipped/delivered order");
|
|
|
|
Apply(new OrderCancelled
|
|
{
|
|
OrderId = Id,
|
|
Reason = reason,
|
|
Version = Version + 1
|
|
});
|
|
}
|
|
|
|
protected override void When(IDomainEvent @event)
|
|
{
|
|
switch (@event)
|
|
{
|
|
case OrderCreated e:
|
|
Id = e.OrderId;
|
|
UserId = e.UserId;
|
|
ShippingAddress = e.ShippingAddress;
|
|
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 e:
|
|
Status = OrderStatus.Paid;
|
|
PaymentId = e.PaymentId;
|
|
break;
|
|
|
|
case OrderShipped e:
|
|
Status = OrderStatus.Shipped;
|
|
TrackingNumber = e.TrackingNumber;
|
|
break;
|
|
|
|
case OrderCancelled:
|
|
Status = OrderStatus.Cancelled;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Snapshot Service
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Service for managing aggregate snapshots.
|
|
/// VI: Service quản lý snapshots của aggregates.
|
|
/// </summary>
|
|
public interface ISnapshotService
|
|
{
|
|
Task<T?> GetLatestSnapshotAsync<T>(Guid aggregateId, CancellationToken ct = default)
|
|
where T : EventSourcedAggregate;
|
|
Task SaveSnapshotAsync<T>(T aggregate, CancellationToken ct = default)
|
|
where T : EventSourcedAggregate;
|
|
}
|
|
|
|
public class SnapshotService : ISnapshotService
|
|
{
|
|
private readonly EventStoreDbContext _context;
|
|
private readonly ILogger<SnapshotService> _logger;
|
|
private const int SnapshotThreshold = 100;
|
|
|
|
public SnapshotService(
|
|
EventStoreDbContext context,
|
|
ILogger<SnapshotService> logger)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<T?> GetLatestSnapshotAsync<T>(
|
|
Guid aggregateId,
|
|
CancellationToken ct = default) where T : EventSourcedAggregate
|
|
{
|
|
var snapshot = await _context.Snapshots
|
|
.Where(s => s.AggregateType == typeof(T).Name
|
|
&& s.AggregateId == aggregateId)
|
|
.OrderByDescending(s => s.Version)
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
if (snapshot == null)
|
|
return null;
|
|
|
|
return JsonSerializer.Deserialize<T>(snapshot.Data);
|
|
}
|
|
|
|
public async Task SaveSnapshotAsync<T>(
|
|
T aggregate,
|
|
CancellationToken ct = default) where T : EventSourcedAggregate
|
|
{
|
|
// EN: Only snapshot if threshold reached
|
|
// VI: Chỉ snapshot nếu đạt ngưỡng
|
|
if (aggregate.Version % SnapshotThreshold != 0)
|
|
return;
|
|
|
|
var snapshot = new Snapshot
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
AggregateType = typeof(T).Name,
|
|
AggregateId = aggregate.Id,
|
|
Data = JsonSerializer.Serialize(aggregate),
|
|
Version = aggregate.Version,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
_context.Snapshots.Add(snapshot);
|
|
await _context.SaveChangesAsync(ct);
|
|
|
|
_logger.LogInformation(
|
|
"Created snapshot for {AggregateType} {AggregateId} at version {Version}",
|
|
typeof(T).Name,
|
|
aggregate.Id,
|
|
aggregate.Version);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Projection Dispatcher
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Dispatches events to projections.
|
|
/// VI: Dispatch events đến projections.
|
|
/// </summary>
|
|
public interface IProjectionDispatcher
|
|
{
|
|
Task DispatchAsync(IDomainEvent @event, CancellationToken ct = default);
|
|
}
|
|
|
|
public class ProjectionDispatcher : IProjectionDispatcher
|
|
{
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly ILogger<ProjectionDispatcher> _logger;
|
|
|
|
public ProjectionDispatcher(
|
|
IServiceProvider serviceProvider,
|
|
ILogger<ProjectionDispatcher> logger)
|
|
{
|
|
_serviceProvider = serviceProvider;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task DispatchAsync(IDomainEvent @event, CancellationToken ct = default)
|
|
{
|
|
var eventType = @event.GetType();
|
|
var handlerType = typeof(IEventHandler<>).MakeGenericType(eventType);
|
|
|
|
var handlers = _serviceProvider.GetServices(handlerType);
|
|
|
|
foreach (var handler in handlers)
|
|
{
|
|
try
|
|
{
|
|
var method = handlerType.GetMethod("HandleAsync");
|
|
await (Task)method!.Invoke(handler, new object[] { @event, ct })!;
|
|
|
|
_logger.LogDebug(
|
|
"Projected {EventType} to {HandlerType}",
|
|
eventType.Name,
|
|
handler!.GetType().Name);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex,
|
|
"Failed to project {EventType} to {HandlerType}",
|
|
eventType.Name,
|
|
handler!.GetType().Name);
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Command Handler with Event Sourcing
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Command handler using event sourcing.
|
|
/// VI: Command handler sử dụng event sourcing.
|
|
/// </summary>
|
|
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
|
|
{
|
|
private readonly IEventSourcedRepository<Order> _repository;
|
|
private readonly IProjectionDispatcher _projections;
|
|
private readonly ISnapshotService _snapshots;
|
|
private readonly ILogger<CreateOrderCommandHandler> _logger;
|
|
|
|
public CreateOrderCommandHandler(
|
|
IEventSourcedRepository<Order> repository,
|
|
IProjectionDispatcher projections,
|
|
ISnapshotService snapshots,
|
|
ILogger<CreateOrderCommandHandler> logger)
|
|
{
|
|
_repository = repository;
|
|
_projections = projections;
|
|
_snapshots = snapshots;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<OrderResult> Handle(
|
|
CreateOrderCommand request,
|
|
CancellationToken ct)
|
|
{
|
|
var orderId = Guid.NewGuid();
|
|
var order = Order.Create(orderId, request.UserId, request.ShippingAddress);
|
|
|
|
foreach (var item in request.Items)
|
|
{
|
|
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
|
|
}
|
|
|
|
// EN: Save events to event store
|
|
// VI: Lưu events vào event store
|
|
await _repository.SaveAsync(order, ct);
|
|
|
|
// EN: Update projections
|
|
// VI: Cập nhật projections
|
|
foreach (var @event in order.UncommittedEvents)
|
|
{
|
|
await _projections.DispatchAsync(@event, ct);
|
|
}
|
|
|
|
// EN: Save snapshot if threshold reached
|
|
// VI: Tạo snapshot nếu đạt ngưỡng
|
|
await _snapshots.SaveSnapshotAsync(order, ct);
|
|
|
|
_logger.LogInformation("Order created: {OrderId}", orderId);
|
|
|
|
return new OrderResult(orderId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 6. DI Registration
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Register event sourcing services.
|
|
/// VI: Đăng ký event sourcing services.
|
|
/// </summary>
|
|
public static class EventSourcingServiceExtensions
|
|
{
|
|
public static IServiceCollection AddEventSourcing(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
// EN: Register Event Store DbContext
|
|
services.AddDbContext<EventStoreDbContext>(options =>
|
|
options.UseNpgsql(configuration.GetConnectionString("EventStore")));
|
|
|
|
// EN: Register repositories
|
|
services.AddScoped(typeof(IEventSourcedRepository<>), typeof(EfCoreEventStore<>));
|
|
|
|
// EN: Register snapshot service
|
|
services.AddScoped<ISnapshotService, SnapshotService>();
|
|
|
|
// EN: Register projection dispatcher
|
|
services.AddScoped<IProjectionDispatcher, ProjectionDispatcher>();
|
|
|
|
// EN: Register projections
|
|
services.AddScoped<IEventHandler<OrderCreated>, OrderProjection>();
|
|
services.AddScoped<IEventHandler<OrderPaid>, OrderProjection>();
|
|
services.AddScoped<IEventHandler<OrderShipped>, OrderProjection>();
|
|
|
|
return services;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7. Temporal Query Example
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// EN: Get order state at specific point in time.
|
|
/// VI: Lấy trạng thái order tại thời điểm cụ thể.
|
|
/// </summary>
|
|
public class GetOrderAtTimeQueryHandler
|
|
: IRequestHandler<GetOrderAtTimeQuery, OrderStateDto?>
|
|
{
|
|
private readonly EventStoreDbContext _context;
|
|
|
|
public GetOrderAtTimeQueryHandler(EventStoreDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public async Task<OrderStateDto?> Handle(
|
|
GetOrderAtTimeQuery request,
|
|
CancellationToken ct)
|
|
{
|
|
var streamId = $"Order-{request.OrderId}";
|
|
|
|
// EN: Get events up to the specified time
|
|
// VI: Lấy events đến thời điểm chỉ định
|
|
var events = await _context.Events
|
|
.Where(e => e.StreamId == streamId && e.OccurredOn <= request.AsOfTime)
|
|
.OrderBy(e => e.Version)
|
|
.ToListAsync(ct);
|
|
|
|
if (!events.Any())
|
|
return null;
|
|
|
|
// EN: Replay events to reconstruct state
|
|
// VI: Phát lại events để khôi phục trạng thái
|
|
var order = new Order();
|
|
order.Load(events.Select(DeserializeEvent));
|
|
|
|
return new OrderStateDto
|
|
{
|
|
OrderId = order.Id,
|
|
Status = order.Status.ToString(),
|
|
TotalAmount = order.TotalAmount,
|
|
ItemCount = order.Items.Count,
|
|
AsOfTime = request.AsOfTime
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
## Database Migration
|
|
|
|
```sql
|
|
-- EN: Create EventStore table / VI: Tạo bảng EventStore
|
|
CREATE TABLE "EventStore" (
|
|
"Id" uuid PRIMARY KEY,
|
|
"StreamId" varchar(200) NOT NULL,
|
|
"EventType" varchar(500) NOT NULL,
|
|
"Data" jsonb NOT NULL,
|
|
"Version" integer NOT NULL,
|
|
"OccurredOn" timestamp with time zone NOT NULL
|
|
);
|
|
|
|
CREATE UNIQUE INDEX "IX_EventStore_StreamId_Version"
|
|
ON "EventStore" ("StreamId", "Version");
|
|
|
|
-- EN: Create Snapshots table / VI: Tạo bảng Snapshots
|
|
CREATE TABLE "Snapshots" (
|
|
"Id" uuid PRIMARY KEY,
|
|
"AggregateType" varchar(200) NOT NULL,
|
|
"AggregateId" uuid NOT NULL,
|
|
"Data" jsonb NOT NULL,
|
|
"Version" integer NOT NULL,
|
|
"CreatedAt" timestamp with time zone NOT NULL
|
|
);
|
|
|
|
CREATE UNIQUE INDEX "IX_Snapshots_Type_Id_Version"
|
|
ON "Snapshots" ("AggregateType", "AggregateId", "Version");
|
|
```
|