282 lines
9.0 KiB
Markdown
282 lines
9.0 KiB
Markdown
# E-Commerce Example: Queries
|
|
|
|
Implement queries and projections for the e-commerce order system.
|
|
|
|
## Query Design
|
|
|
|
Queries fetch data from read models (projections) optimized for querying:
|
|
|
|
- ✅ `GetOrderQuery` - Fetch single order details
|
|
- ✅ `ListOrdersQuery` - List orders with filtering
|
|
- ✅ `GetOrderHistoryQuery` - Customer order history
|
|
- ✅ `GetOrderAnalyticsQuery` - Analytics dashboard data
|
|
|
|
## GetOrderQuery
|
|
|
|
```csharp
|
|
public record GetOrderQuery
|
|
{
|
|
public string OrderId { get; init; } = string.Empty;
|
|
}
|
|
|
|
public class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, OrderDto>
|
|
{
|
|
private readonly IOrderRepository _repository;
|
|
|
|
public async Task<OrderDto> HandleAsync(GetOrderQuery query, CancellationToken ct)
|
|
{
|
|
var order = await _repository.GetByIdAsync(query.OrderId, ct);
|
|
|
|
if (order == null)
|
|
throw new NotFoundException($"Order {query.OrderId} not found");
|
|
|
|
return new OrderDto
|
|
{
|
|
OrderId = order.OrderId,
|
|
CustomerId = order.CustomerId,
|
|
CustomerName = order.CustomerName,
|
|
Lines = order.Lines,
|
|
TotalAmount = order.TotalAmount,
|
|
Status = order.Status.ToString(),
|
|
PlacedAt = order.PlacedAt,
|
|
PaidAt = order.PaidAt,
|
|
ShippedAt = order.ShippedAt,
|
|
DeliveredAt = order.DeliveredAt
|
|
};
|
|
}
|
|
}
|
|
|
|
public record OrderDto
|
|
{
|
|
public string OrderId { get; init; } = string.Empty;
|
|
public string CustomerId { get; init; } = string.Empty;
|
|
public string CustomerName { get; init; } = string.Empty;
|
|
public List<OrderLineDto> Lines { get; init; } = new();
|
|
public decimal TotalAmount { get; init; }
|
|
public string Status { get; init; } = string.Empty;
|
|
public DateTimeOffset PlacedAt { get; init; }
|
|
public DateTimeOffset? PaidAt { get; init; }
|
|
public DateTimeOffset? ShippedAt { get; init; }
|
|
public DateTimeOffset? DeliveredAt { get; init; }
|
|
}
|
|
```
|
|
|
|
## ListOrdersQuery
|
|
|
|
```csharp
|
|
public record ListOrdersQuery
|
|
{
|
|
public string? CustomerId { get; init; }
|
|
public string? Status { get; init; }
|
|
public DateTimeOffset? StartDate { get; init; }
|
|
public DateTimeOffset? EndDate { get; init; }
|
|
public int Page { get; init; } = 1;
|
|
public int PageSize { get; init; } = 20;
|
|
}
|
|
|
|
public class ListOrdersQueryHandler : IQueryHandler<ListOrdersQuery, PagedResult<OrderSummaryDto>>
|
|
{
|
|
private readonly IOrderRepository _repository;
|
|
|
|
public async Task<PagedResult<OrderSummaryDto>> HandleAsync(ListOrdersQuery query, CancellationToken ct)
|
|
{
|
|
var (orders, totalCount) = await _repository.ListAsync(
|
|
customerId: query.CustomerId,
|
|
status: query.Status,
|
|
startDate: query.StartDate,
|
|
endDate: query.EndDate,
|
|
page: query.Page,
|
|
pageSize: query.PageSize,
|
|
ct);
|
|
|
|
var dtos = orders.Select(o => new OrderSummaryDto
|
|
{
|
|
OrderId = o.OrderId,
|
|
CustomerName = o.CustomerName,
|
|
TotalAmount = o.TotalAmount,
|
|
Status = o.Status.ToString(),
|
|
PlacedAt = o.PlacedAt
|
|
}).ToList();
|
|
|
|
return new PagedResult<OrderSummaryDto>
|
|
{
|
|
Items = dtos,
|
|
Page = query.Page,
|
|
PageSize = query.PageSize,
|
|
TotalCount = totalCount,
|
|
TotalPages = (int)Math.Ceiling(totalCount / (double)query.PageSize)
|
|
};
|
|
}
|
|
}
|
|
|
|
public record OrderSummaryDto
|
|
{
|
|
public string OrderId { get; init; } = string.Empty;
|
|
public string CustomerName { get; init; } = string.Empty;
|
|
public decimal TotalAmount { get; init; }
|
|
public string Status { get; init; } = string.Empty;
|
|
public DateTimeOffset PlacedAt { get; init; }
|
|
}
|
|
```
|
|
|
|
## Order Summary Projection
|
|
|
|
Build an optimized read model for queries:
|
|
|
|
```csharp
|
|
public class OrderSummaryProjection : IDynamicProjection, IResettableProjection
|
|
{
|
|
private readonly IEventStreamStore _eventStore;
|
|
private readonly ICheckpointStore _checkpointStore;
|
|
private readonly IOrderRepository _repository;
|
|
|
|
public string ProjectionName => "order-summary";
|
|
|
|
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)
|
|
{
|
|
switch (@event)
|
|
{
|
|
case OrderPlacedEvent e:
|
|
await _repository.CreateAsync(new OrderSummary
|
|
{
|
|
OrderId = e.OrderId,
|
|
CustomerId = e.CustomerId,
|
|
CustomerName = e.CustomerName,
|
|
CustomerEmail = e.CustomerEmail,
|
|
Lines = e.Lines,
|
|
TotalAmount = e.TotalAmount,
|
|
Status = OrderStatus.Placed,
|
|
PlacedAt = e.PlacedAt
|
|
}, ct);
|
|
break;
|
|
|
|
case OrderPaidEvent e:
|
|
var order = await _repository.GetByIdAsync(e.OrderId, ct);
|
|
if (order != null)
|
|
{
|
|
order.Status = OrderStatus.Paid;
|
|
order.PaidAt = e.PaidAt;
|
|
await _repository.UpdateAsync(order, ct);
|
|
}
|
|
break;
|
|
|
|
case OrderShippedEvent e:
|
|
var shippedOrder = await _repository.GetByIdAsync(e.OrderId, ct);
|
|
if (shippedOrder != null)
|
|
{
|
|
shippedOrder.Status = OrderStatus.Shipped;
|
|
shippedOrder.ShippedAt = e.ShippedAt;
|
|
shippedOrder.TrackingNumber = e.TrackingNumber;
|
|
await _repository.UpdateAsync(shippedOrder, ct);
|
|
}
|
|
break;
|
|
|
|
case OrderDeliveredEvent e:
|
|
var deliveredOrder = await _repository.GetByIdAsync(e.OrderId, ct);
|
|
if (deliveredOrder != null)
|
|
{
|
|
deliveredOrder.Status = OrderStatus.Delivered;
|
|
deliveredOrder.DeliveredAt = e.DeliveredAt;
|
|
await _repository.UpdateAsync(deliveredOrder, ct);
|
|
}
|
|
break;
|
|
|
|
case OrderCancelledEvent e:
|
|
var cancelledOrder = await _repository.GetByIdAsync(e.OrderId, ct);
|
|
if (cancelledOrder != null)
|
|
{
|
|
cancelledOrder.Status = OrderStatus.Cancelled;
|
|
cancelledOrder.CancelledAt = e.CancelledAt;
|
|
await _repository.UpdateAsync(cancelledOrder, ct);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
public async Task ResetAsync(CancellationToken ct)
|
|
{
|
|
await _repository.DeleteAllAsync(ct);
|
|
await _checkpointStore.SaveCheckpointAsync(ProjectionName, 0, ct);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Customer Order History Projection
|
|
|
|
Denormalized projection for fast customer queries:
|
|
|
|
```csharp
|
|
public class CustomerOrderHistoryProjection : IDynamicProjection
|
|
{
|
|
public string ProjectionName => "customer-order-history";
|
|
|
|
private async Task HandleEventAsync(object @event, CancellationToken ct)
|
|
{
|
|
switch (@event)
|
|
{
|
|
case OrderPlacedEvent e:
|
|
var customer = await _repository.GetCustomerAsync(e.CustomerId, ct)
|
|
?? new CustomerOrderHistory { CustomerId = e.CustomerId, Name = e.CustomerName };
|
|
|
|
customer.Orders.Add(new OrderSummary
|
|
{
|
|
OrderId = e.OrderId,
|
|
TotalAmount = e.TotalAmount,
|
|
Status = OrderStatus.Placed,
|
|
PlacedAt = e.PlacedAt
|
|
});
|
|
|
|
customer.TotalSpent += e.TotalAmount;
|
|
customer.OrderCount++;
|
|
|
|
await _repository.SaveCustomerAsync(customer, ct);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public class CustomerOrderHistory
|
|
{
|
|
public string CustomerId { get; set; } = string.Empty;
|
|
public string Name { get; set; } = string.Empty;
|
|
public List<OrderSummary> Orders { get; set; } = new();
|
|
public decimal TotalSpent { get; set; }
|
|
public int OrderCount { get; set; }
|
|
}
|
|
```
|
|
|
|
## Query Registration
|
|
|
|
```csharp
|
|
// In Program.cs
|
|
builder.Services.AddQuery<GetOrderQuery, OrderDto, GetOrderQueryHandler>();
|
|
builder.Services.AddQuery<ListOrdersQuery, PagedResult<OrderSummaryDto>, ListOrdersQueryHandler>();
|
|
|
|
// Register projections
|
|
builder.Services.AddSingleton<IDynamicProjection, OrderSummaryProjection>();
|
|
builder.Services.AddSingleton<IDynamicProjection, CustomerOrderHistoryProjection>();
|
|
|
|
// Auto-start projections
|
|
builder.Services.AddProjectionRunner();
|
|
```
|
|
|
|
## See Also
|
|
|
|
- [05-projections.md](05-projections.md) - More projection examples
|
|
- [07-http-api.md](07-http-api.md) - HTTP endpoints for queries
|
|
- [Query Design Best Practices](../../best-practices/query-design.md)
|