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

261 lines
8.5 KiB
Markdown

# 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
```csharp
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
```csharp
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
```csharp
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
```csharp
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
```csharp
// 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
- [04-queries.md](04-queries.md) - Query the order data
- [06-sagas.md](06-sagas.md) - Order fulfillment workflow
- [Command Design Best Practices](../../best-practices/command-design.md)