Skip to content

iii. ✨Projections

CØDE N!NJΔ edited this page Jul 27, 2025 · 2 revisions

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.

What are Projections?

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.

Types of Projections

Event-Sourced Projections

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 }

State-Based Projections

Built by querying current aggregate state and transforming it:

Customer aggregate → CustomerViewModel: { Name, Email, TotalOrders, LastLoginDate }

Projections as ViewModels

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.

Example: E-commerce Order Management

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);
    }
}

Advanced Projection Patterns

Denormalized ViewModels

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; }
}

Temporal Projections

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.
}

Aggregated ViewModels

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; }
}

Implementation Strategies

Eventual Consistency

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");
    }
}

Projection Versioning

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;
}

Snapshot Projections

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
}

Benefits of Projections

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

Best Practices

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.