dotnet-cqrs/docs/tutorials/ecommerce-example/05-projections.md

5.0 KiB

E-Commerce Example: Projections

Build analytics and reporting projections for the e-commerce system.

Analytics Projection

Create real-time analytics from order events:

public class OrderAnalyticsProjection : IDynamicProjection, IResettableProjection
{
    private readonly IEventStreamStore _eventStore;
    private readonly ICheckpointStore _checkpointStore;
    private readonly IAnalyticsRepository _repository;

    public string ProjectionName => "order-analytics";

    public async Task RunAsync(CancellationToken ct)
    {
        var checkpoint = await _checkpointStore.GetCheckpointAsync(ProjectionName, ct);

        await foreach (var storedEvent in _eventStore.ReadStreamAsync(
            "orders",
            fromOffset: checkpoint + 1,
            cancellationToken: ct))
        {
            await HandleEventAsync(storedEvent.Data, ct);
            await _checkpointStore.SaveCheckpointAsync(ProjectionName, storedEvent.Offset, ct);
        }
    }

    private async Task HandleEventAsync(object @event, CancellationToken ct)
    {
        var analytics = await _repository.GetOrCreateAsync(ct);

        switch (@event)
        {
            case OrderPlacedEvent e:
                analytics.TotalOrders++;
                analytics.TotalRevenue += e.TotalAmount;
                analytics.OrdersByStatus["Placed"]++;
                
                foreach (var line in e.Lines)
                {
                    if (!analytics.ProductSales.ContainsKey(line.ProductId))
                    {
                        analytics.ProductSales[line.ProductId] = new ProductSales
                        {
                            ProductId = line.ProductId,
                            ProductName = line.ProductName,
                            UnitsSold = 0,
                            Revenue = 0
                        };
                    }

                    analytics.ProductSales[line.ProductId].UnitsSold += line.Quantity;
                    analytics.ProductSales[line.ProductId].Revenue += line.LineTotal;
                }
                break;

            case OrderCancelledEvent e:
                analytics.OrdersByStatus["Placed"]--;
                analytics.OrdersByStatus["Cancelled"]++;
                analytics.CancellationRate = (double)analytics.OrdersByStatus["Cancelled"] / analytics.TotalOrders;
                break;

            case OrderPaidEvent e:
                analytics.OrdersByStatus["Placed"]--;
                analytics.OrdersByStatus["Paid"]++;
                break;

            case OrderShippedEvent e:
                analytics.OrdersByStatus["Paid"]--;
                analytics.OrdersByStatus["Shipped"]++;
                break;

            case OrderDeliveredEvent e:
                analytics.OrdersByStatus["Shipped"]--;
                analytics.OrdersByStatus["Delivered"]++;
                analytics.FulfillmentRate = (double)analytics.OrdersByStatus["Delivered"] / analytics.TotalOrders;
                break;
        }

        await _repository.SaveAsync(analytics, ct);
    }

    public async Task ResetAsync(CancellationToken ct)
    {
        await _repository.DeleteAllAsync(ct);
        await _checkpointStore.SaveCheckpointAsync(ProjectionName, 0, ct);
    }
}

public class OrderAnalytics
{
    public int TotalOrders { get; set; }
    public decimal TotalRevenue { get; set; }
    public Dictionary<string, int> OrdersByStatus { get; set; } = new();
    public Dictionary<string, ProductSales> ProductSales { get; set; } = new();
    public double CancellationRate { get; set; }
    public double FulfillmentRate { get; set; }
}

public class ProductSales
{
    public string ProductId { get; set; } = string.Empty;
    public string ProductName { get; set; } = string.Empty;
    public int UnitsSold { get; set; }
    public decimal Revenue { get; set; }
}

Top Products Projection

Track top-selling products:

public class TopProductsProjection : IDynamicProjection
{
    public string ProjectionName => "top-products";

    private async Task HandleEventAsync(object @event, CancellationToken ct)
    {
        if (@event is OrderPlacedEvent e)
        {
            foreach (var line in e.Lines)
            {
                await _repository.IncrementSalesAsync(line.ProductId, line.Quantity, line.LineTotal, ct);
            }
        }
    }
}

public class TopProductsQuery : IQueryHandler<GetTopProductsQuery, List<ProductSalesDto>>
{
    private readonly ITopProductsRepository _repository;

    public async Task<List<ProductSalesDto>> HandleAsync(GetTopProductsQuery query, CancellationToken ct)
    {
        var topProducts = await _repository.GetTopAsync(query.Limit, ct);

        return topProducts.Select(p => new ProductSalesDto
        {
            ProductId = p.ProductId,
            ProductName = p.ProductName,
            UnitsSold = p.UnitsSold,
            Revenue = p.Revenue
        }).ToList();
    }
}

See Also