Files
pos-system/microservices/.agent/skills/event-sourcing/references/REFERENCE.md
Ho Ngoc Hai 76d75c753b Migrate
2026-05-23 18:37:02 +07:00

15 KiB

Event Sourcing - Reference Examples

Complete Implementation Examples

1. Event Store DbContext

/// <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

/// <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

/// <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

/// <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

/// <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

/// <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

/// <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

-- 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");