dotnet-cqrs/docs/tutorials/ecommerce-example/04-queries.md

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)