261 lines
8.5 KiB
Markdown
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)
|