-
Notifications
You must be signed in to change notification settings - Fork 0
iii. ✨Projections
Projections in DDD are a powerful pattern for creating read-optimized data models from your domain events or aggregate state. They're essential for implementing CQRS (Command Query Responsibility Segregation) and building efficient query models, often in the form of ViewModels.
Projections are read-only data structures that represent a specific view of your domain data, optimized for particular query needs. They're built by processing domain events or aggregate state and transforming them into formats that are ideal for display or reporting purposes.
Built by replaying domain events in sequence:
OrderCreated → CustomerOrderSummary: { CustomerId, OrderCount: 1, TotalSpent: $100 }
OrderShipped → CustomerOrderSummary: { CustomerId, OrderCount: 1, TotalSpent: $100, LastShipDate: today }
OrderCancelled → CustomerOrderSummary: { CustomerId, OrderCount: 0, TotalSpent: $0 }
Built by querying current aggregate state and transforming it:
Customer aggregate → CustomerViewModel: { Name, Email, TotalOrders, LastLoginDate }
ViewModels are a common type of projection that represents exactly what the UI needs to display, removing the impedance mismatch between domain models and presentation layers.
Domain Events:
public class OrderCreatedEvent : IDomainEvent
{
public string OrderId { get; set; }
public string CustomerId { get; set; }
public decimal TotalAmount { get; set; }
public DateTime OrderDate { get; set; }
public List<OrderItem> Items { get; set; }
}
public class OrderShippedEvent : IDomainEvent
{
public string OrderId { get; set; }
public DateTime ShippedDate { get; set; }
public string TrackingNumber { get; set; }
}
ViewModel Projection:
public class OrderSummaryViewModel
{
public string OrderId { get; set; }
public string CustomerName { get; set; }
public decimal TotalAmount { get; set; }
public DateTime OrderDate { get; set; }
public string Status { get; set; }
public int ItemCount { get; set; }
public string TrackingNumber { get; set; }
}
Projection Handler:
public class OrderSummaryProjectionHandler :
IEventHandler<OrderCreatedEvent>,
IEventHandler<OrderShippedEvent>
{
private readonly IOrderSummaryRepository _repository;
private readonly ICustomerRepository _customerRepository;
public async Task Handle(OrderCreatedEvent @event)
{
var customer = await _customerRepository.GetByIdAsync(@event.CustomerId);
var viewModel = new OrderSummaryViewModel
{
OrderId = @event.OrderId,
CustomerName = customer.FullName,
TotalAmount = @event.TotalAmount,
OrderDate = @event.OrderDate,
Status = "Pending",
ItemCount = @event.Items.Count,
TrackingNumber = null
};
await _repository.SaveAsync(viewModel);
}
public async Task Handle(OrderShippedEvent @event)
{
var viewModel = await _repository.GetByOrderIdAsync(@event.OrderId);
viewModel.Status = "Shipped";
viewModel.TrackingNumber = @event.TrackingNumber;
await _repository.UpdateAsync(viewModel);
}
}
Combine data from multiple aggregates for efficient querying:
public class CustomerDashboardViewModel
{
public string CustomerId { get; set; }
public string CustomerName { get; set; }
public string Email { get; set; }
// From Order aggregate
public int TotalOrders { get; set; }
public decimal TotalSpent { get; set; }
public DateTime LastOrderDate { get; set; }
// From Support aggregate
public int OpenTickets { get; set; }
public string AccountManagerName { get; set; }
// From Loyalty aggregate
public int LoyaltyPoints { get; set; }
public string MembershipTier { get; set; }
}
Show data as it existed at specific points in time:
public class ProductPriceHistoryViewModel
{
public string ProductId { get; set; }
public string ProductName { get; set; }
public List<PricePoint> PriceHistory { get; set; }
}
public class PricePoint
{
public decimal Price { get; set; }
public DateTime EffectiveDate { get; set; }
public string Reason { get; set; } // "Promotion", "Cost Increase", etc.
}
Provide summary data across multiple entities:
public class SalesReportViewModel
{
public DateTime ReportDate { get; set; }
public decimal TotalRevenue { get; set; }
public int OrderCount { get; set; }
public decimal AverageOrderValue { get; set; }
public List<CategorySales> SalesByCategory { get; set; }
public List<TopSellingProduct> TopProducts { get; set; }
}
Projections are eventually consistent - they might lag behind the write model:
public class ProjectionService
{
public async Task RebuildProjection<T>(string projectionName)
{
// Mark projection as rebuilding
await SetProjectionStatus(projectionName, "Rebuilding");
// Replay all events from the beginning
var events = await _eventStore.GetAllEventsAsync();
foreach (var @event in events)
{
await _projectionHandler.Handle(@event);
}
await SetProjectionStatus(projectionName, "Current");
}
}
Handle schema changes over time:
public class OrderSummaryViewModelV2 : OrderSummaryViewModel
{
public string ShippingMethod { get; set; } // New field
public DateTime EstimatedDeliveryDate { get; set; } // New field
public int Version { get; set; } = 2;
}
For performance, create periodic snapshots:
public class CustomerSnapshotViewModel
{
public string CustomerId { get; set; }
public DateTime SnapshotDate { get; set; }
public CustomerSummaryData Summary { get; set; }
// Built from events up to SnapshotDate
// New events since snapshot are applied on top
}
Query Optimization: Data shaped exactly for specific use cases
Performance: No complex joins or transformations at query time
Scalability: Read models can be stored in different databases optimized for queries
Flexibility: Multiple projections can exist for the same domain data
UI Alignment: ViewModels match exactly what the presentation layer needs
Single Responsibility: Each projection serves a specific query need
Idempotency: Projection handlers should be idempotent for replay scenarios
Error Handling: Failed projections shouldn't break the write side
Monitoring: Track projection lag and health
Caching: Consider caching frequently accessed projections
Partitioning: Large projections can be partitioned for better performance
Projections bridge the gap between your rich domain model and the practical needs of your application's query requirements, providing an elegant solution for presenting complex domain data in user-friendly formats.