157 lines
5.0 KiB
Markdown
157 lines
5.0 KiB
Markdown
# E-Commerce Example: Projections
|
|
|
|
Build analytics and reporting projections for the e-commerce system.
|
|
|
|
## Analytics Projection
|
|
|
|
Create real-time analytics from order events:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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
|
|
|
|
- [06-sagas.md](06-sagas.md) - Order fulfillment workflow
|
|
- [Projections](../../event-streaming/projections/README.md)
|