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

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)