Migrate
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
# 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");
|
||||
```
|
||||
Reference in New Issue
Block a user