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

9.0 KiB

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

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

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:

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:

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

// 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