dotnet-cqrs/docs/tutorials/ecommerce-example/03-commands.md

8.5 KiB

E-Commerce Example: Commands

Implement commands and handlers for the e-commerce order system.

Command Design

Commands represent user intentions and are named using imperative verbs:

  • PlaceOrderCommand - Place a new order
  • ProcessPaymentCommand - Process payment for order
  • ShipOrderCommand - Ship an order
  • CancelOrderCommand - Cancel an order

PlaceOrderCommand

public record PlaceOrderCommand
{
    public string CustomerId { get; init; } = string.Empty;
    public List<OrderLineDto> Lines { get; init; } = new();
    public string ShippingAddress { get; init; } = string.Empty;
}

public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
    public PlaceOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Lines).NotEmpty().WithMessage("Order must have at least one item");
        RuleFor(x => x.ShippingAddress).NotEmpty();
        RuleForEach(x => x.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.ProductId).NotEmpty();
            line.RuleFor(l => l.Quantity).GreaterThan(0);
        });
    }
}

public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, string>
{
    private readonly IEventStreamStore _eventStore;
    private readonly IProductRepository _products;
    private readonly ICustomerRepository _customers;

    public async Task<string> HandleAsync(PlaceOrderCommand command, CancellationToken ct)
    {
        // 1. Validate customer exists
        var customer = await _customers.GetByIdAsync(command.CustomerId, ct);
        if (customer == null)
            throw new InvalidOperationException("Customer not found");

        // 2. Load products and calculate total
        var orderLines = new List<OrderLineDto>();
        decimal totalAmount = 0;

        foreach (var line in command.Lines)
        {
            var product = await _products.GetByIdAsync(line.ProductId, ct);
            if (product == null)
                throw new InvalidOperationException($"Product {line.ProductId} not found");

            if (product.AvailableStock < line.Quantity)
                throw new InvalidOperationException($"Insufficient stock for {product.Name}");

            var orderLine = new OrderLineDto
            {
                ProductId = product.ProductId,
                ProductName = product.Name,
                Quantity = line.Quantity,
                UnitPrice = product.Price,
                LineTotal = line.Quantity * product.Price
            };

            orderLines.Add(orderLine);
            totalAmount += orderLine.LineTotal;
        }

        // 3. Create order and emit event
        var orderId = Guid.NewGuid().ToString();
        var streamName = $"order-{orderId}";

        await _eventStore.AppendAsync(streamName, new OrderPlacedEvent
        {
            OrderId = orderId,
            CustomerId = customer.CustomerId,
            CustomerName = customer.Name,
            CustomerEmail = customer.Email,
            Lines = orderLines,
            TotalAmount = totalAmount,
            PlacedAt = DateTimeOffset.UtcNow
        }, ct);

        return orderId;
    }
}

ProcessPaymentCommand

public record ProcessPaymentCommand
{
    public string OrderId { get; init; } = string.Empty;
    public string PaymentMethod { get; init; } = string.Empty;
    public string PaymentToken { get; init; } = string.Empty;
}

public class ProcessPaymentCommandHandler : ICommandHandler<ProcessPaymentCommand>
{
    private readonly IAggregateRepository<Order> _repository;
    private readonly IPaymentService _paymentService;
    private readonly IEventStreamStore _eventStore;

    public async Task HandleAsync(ProcessPaymentCommand command, CancellationToken ct)
    {
        // 1. Load order aggregate
        var order = await _repository.LoadAsync(command.OrderId, ct);

        if (order.Status != OrderStatus.Placed)
            throw new InvalidOperationException("Order must be in 'Placed' status to process payment");

        // 2. Process payment
        var paymentResult = await _paymentService.ChargeAsync(
            command.OrderId,
            order.TotalAmount,
            command.PaymentMethod,
            command.PaymentToken,
            ct);

        if (!paymentResult.Success)
        {
            await _eventStore.AppendAsync($"order-{command.OrderId}", new PaymentFailedEvent
            {
                OrderId = command.OrderId,
                Reason = paymentResult.ErrorMessage,
                FailedAt = DateTimeOffset.UtcNow
            }, ct);

            throw new InvalidOperationException($"Payment failed: {paymentResult.ErrorMessage}");
        }

        // 3. Emit payment events
        await _eventStore.AppendAsync($"order-{command.OrderId}", new PaymentProcessedEvent
        {
            PaymentId = paymentResult.PaymentId,
            OrderId = command.OrderId,
            Amount = order.TotalAmount,
            ProcessedAt = DateTimeOffset.UtcNow
        }, ct);

        await _eventStore.AppendAsync($"order-{command.OrderId}", new OrderPaidEvent
        {
            OrderId = command.OrderId,
            PaymentId = paymentResult.PaymentId,
            PaymentMethod = command.PaymentMethod,
            Amount = order.TotalAmount,
            PaidAt = DateTimeOffset.UtcNow
        }, ct);
    }
}

ShipOrderCommand

public record ShipOrderCommand
{
    public string OrderId { get; init; } = string.Empty;
    public string Carrier { get; init; } = string.Empty;
}

public class ShipOrderCommandHandler : ICommandHandler<ShipOrderCommand>
{
    private readonly IAggregateRepository<Order> _repository;
    private readonly IShippingService _shippingService;
    private readonly IEventStreamStore _eventStore;

    public async Task HandleAsync(ShipOrderCommand command, CancellationToken ct)
    {
        var order = await _repository.LoadAsync(command.OrderId, ct);

        if (order.Status != OrderStatus.Paid)
            throw new InvalidOperationException("Order must be paid before shipping");

        // Create shipment
        var shipment = await _shippingService.CreateShipmentAsync(
            command.OrderId,
            order.Lines,
            command.Carrier,
            ct);

        // Emit event
        await _eventStore.AppendAsync($"order-{command.OrderId}", new OrderShippedEvent
        {
            OrderId = command.OrderId,
            ShipmentId = shipment.ShipmentId,
            TrackingNumber = shipment.TrackingNumber,
            Carrier = command.Carrier,
            EstimatedDelivery = shipment.EstimatedDelivery,
            ShippedAt = DateTimeOffset.UtcNow
        }, ct);
    }
}

CancelOrderCommand

public record CancelOrderCommand
{
    public string OrderId { get; init; } = string.Empty;
    public string Reason { get; init; } = string.Empty;
}

public class CancelOrderCommandHandler : ICommandHandler<CancelOrderCommand>
{
    private readonly IAggregateRepository<Order> _repository;
    private readonly IEventStreamStore _eventStore;

    public async Task HandleAsync(CancelOrderCommand command, CancellationToken ct)
    {
        var order = await _repository.LoadAsync(command.OrderId, ct);

        if (order.Status == OrderStatus.Shipped || order.Status == OrderStatus.Delivered)
            throw new InvalidOperationException("Cannot cancel shipped or delivered orders");

        if (order.Status == OrderStatus.Cancelled)
            throw new InvalidOperationException("Order is already cancelled");

        // Emit cancellation event
        await _eventStore.AppendAsync($"order-{command.OrderId}", new OrderCancelledEvent
        {
            OrderId = command.OrderId,
            CancelledBy = "Customer",  // Or get from auth context
            Reason = command.Reason,
            RefundIssued = order.Status == OrderStatus.Paid,
            CancelledAt = DateTimeOffset.UtcNow
        }, ct);
    }
}

Command Registration

// In Program.cs
builder.Services.AddCommand<PlaceOrderCommand, string, PlaceOrderCommandHandler>();
builder.Services.AddCommand<ProcessPaymentCommand, ProcessPaymentCommandHandler>();
builder.Services.AddCommand<ShipOrderCommand, ShipOrderCommandHandler>();
builder.Services.AddCommand<CancelOrderCommand, CancelOrderCommandHandler>();

// Register validators
builder.Services.AddTransient<IValidator<PlaceOrderCommand>, PlaceOrderCommandValidator>();

See Also