Projection Migration: Marten → Whizbang Perspectives¶
Verified by tests
IPerspectiveForTests, IGlobalPerspectiveForTests, ProjectionToPerspectiveTransformerTests — library CI run #31657041675 (2026-08-13)
This guide covers converting Marten projections to Whizbang Perspectives.
Key Differences¶
| Aspect | Marten Projection | Whizbang Perspective |
|---|---|---|
| Execution | Can be async | Synchronous (pure functions) |
| Mutation | Can mutate model | Must return new model |
| Side effects | Allowed | Not allowed |
| Multiple events | Separate Apply methods |
Variadic interface |
| State | Can access external state | Only event + current model |
Why Pure Functions?¶
Whizbang Perspectives are pure functions by design:
- Deterministic: Same input always produces same output
- Testable: No mocks needed, just input → output
- Replayable: Can rebuild from any point in time
- Time-travel debugging: Easy to debug historical state
- AOT-compatible: No reflection needed
Single-Stream Projection Migration¶
Marten Single-Stream¶
Marten Single-Stream
// Marten: Can mutate, can have side effects
public class OrderSummaryProjection : SingleStreamProjection<OrderSummary> {
public OrderSummary Create(OrderCreated @event) {
return new OrderSummary {
Id = @event.OrderId,
CustomerId = @event.CustomerId,
Status = OrderStatus.Created,
Total = @event.Items.Sum(i => i.Price * i.Quantity),
CreatedAt = @event.Timestamp
};
}
public void Apply(OrderItemAdded @event, OrderSummary model) {
model.Total += @event.Price * @event.Quantity;
model.ItemCount++;
}
public void Apply(OrderShipped @event, OrderSummary model) {
model.Status = OrderStatus.Shipped;
model.ShippedAt = @event.Timestamp;
}
public void Apply(OrderCancelled @event, OrderSummary model) {
model.Status = OrderStatus.Cancelled;
model.CancelledAt = @event.Timestamp;
}
}
Whizbang Perspective¶
Whizbang Perspective
// Whizbang: Pure functions, returns new model
// Event types must implement IEvent; up to 20 event types per perspective
public class OrderSummaryPerspective :
IPerspectiveFor<OrderSummary, OrderCreated, OrderItemAdded, OrderShipped, OrderCancelled> {
public OrderSummary Apply(OrderSummary current, OrderCreated @event) {
return new OrderSummary {
Id = @event.OrderId,
CustomerId = @event.CustomerId,
Status = OrderStatus.Created,
Total = @event.Items.Sum(i => i.Price * i.Quantity),
ItemCount = @event.Items.Count,
CreatedAt = @event.Timestamp
};
}
public OrderSummary Apply(OrderSummary current, OrderItemAdded @event) {
return current with {
Total = current.Total + (@event.Price * @event.Quantity),
ItemCount = current.ItemCount + 1
};
}
public OrderSummary Apply(OrderSummary current, OrderShipped @event) {
return current with {
Status = OrderStatus.Shipped,
ShippedAt = @event.Timestamp
};
}
public OrderSummary Apply(OrderSummary current, OrderCancelled @event) {
return current with {
Status = OrderStatus.Cancelled,
CancelledAt = @event.Timestamp
};
}
}
Multi-Stream Projection Migration¶
Marten Multi-Stream¶
Marten Multi-Stream
// Marten: Aggregates across streams
public class CustomerOrderStatsProjection :
MultiStreamProjection<CustomerOrderStats, Guid> {
public CustomerOrderStatsProjection() {
Identity<OrderCreated>(e => e.CustomerId);
Identity<OrderCompleted>(e => e.CustomerId);
}
public CustomerOrderStats Create(OrderCreated @event) {
return new CustomerOrderStats {
CustomerId = @event.CustomerId,
TotalOrders = 1,
TotalSpent = @event.Total
};
}
public void Apply(OrderCreated @event, CustomerOrderStats model) {
model.TotalOrders++;
model.TotalSpent += @event.Total;
}
public void Apply(OrderCompleted @event, CustomerOrderStats model) {
model.CompletedOrders++;
}
}
Whizbang Global Perspective¶
Whizbang Global Perspective
// Whizbang: Global perspective with partition key
// GetPartitionKey mirrors Marten's Identity() method
// (variants currently support up to 3 event types)
public class CustomerOrderStatsPerspective :
IGlobalPerspectiveFor<CustomerOrderStats, Guid, OrderCreated, OrderCompleted> {
public Guid GetPartitionKey(OrderCreated @event) => @event.CustomerId;
public Guid GetPartitionKey(OrderCompleted @event) => @event.CustomerId;
public CustomerOrderStats Apply(CustomerOrderStats current, OrderCreated @event) {
if (current == null) {
return new CustomerOrderStats {
CustomerId = @event.CustomerId,
TotalOrders = 1,
TotalSpent = @event.Total
};
}
return current with {
TotalOrders = current.TotalOrders + 1,
TotalSpent = current.TotalSpent + @event.Total
};
}
public CustomerOrderStats Apply(CustomerOrderStats current, OrderCompleted @event) {
return current with {
CompletedOrders = current.CompletedOrders + 1
};
}
}
Converting Mutation to Immutable¶
Pattern: Mutation → with Expression¶
Marten (mutation):
Pattern: Mutation → with Expression
public void Apply(OrderUpdated @event, OrderSummary model) {
model.Title = @event.Title;
model.Total = @event.Total;
model.UpdatedAt = @event.Timestamp;
}
Whizbang (immutable):
Pattern: Mutation → with Expression (2)
public OrderSummary Apply(OrderSummary current, OrderUpdated @event) {
return current with {
Title = @event.Title,
Total = @event.Total,
UpdatedAt = @event.Timestamp
};
}
Pattern: Conditional Logic¶
Marten (conditional mutation):
Pattern: Conditional Logic
public void Apply(PaymentReceived @event, OrderSummary model) {
model.PaidAmount += @event.Amount;
if (model.PaidAmount >= model.Total) {
model.Status = OrderStatus.Paid;
}
}
Whizbang (conditional immutable):
Pattern: Conditional Logic (2)
public OrderSummary Apply(OrderSummary current, PaymentReceived @event) {
var newPaidAmount = current.PaidAmount + @event.Amount;
var newStatus = newPaidAmount >= current.Total
? OrderStatus.Paid
: current.Status;
return current with {
PaidAmount = newPaidAmount,
Status = newStatus
};
}
Pattern: Collection Updates¶
Marten (list mutation):
Pattern: Collection Updates
public void Apply(ItemAdded @event, ShoppingCart model) {
model.Items.Add(new CartItem(@event.ProductId, @event.Quantity));
}
Whizbang (immutable collection):
Pattern: Collection Updates (2)
public ShoppingCart Apply(ShoppingCart current, ItemAdded @event) {
var newItems = current.Items
.Append(new CartItem(@event.ProductId, @event.Quantity))
.ToList();
return current with { Items = newItems };
}
Model Definition Changes¶
Use Records for Immutability¶
Before (class with mutable properties):
Use Records for Immutability
public class OrderSummary {
public Guid Id { get; set; }
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
}
After (record with init properties):
Use Records for Immutability - OrderSummary
public sealed record OrderSummary {
public required Guid Id { get; init; }
public OrderStatus Status { get; init; }
public decimal Total { get; init; }
public int ItemCount { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ShippedAt { get; init; }
}
Async Operations¶
Marten Async Projections¶
Marten Async Projections
// Marten: Can do async work in projections
public async Task Apply(OrderCreated @event, OrderSummary model, IQuerySession session) {
var customer = await session.LoadAsync<Customer>(@event.CustomerId);
model.CustomerName = customer?.Name;
}
Whizbang: Move Async to Receptor¶
Perspectives must be pure. Move async logic to receptors:
Whizbang: Move Async to Receptor
// Receptor enriches event before storing
public class CreateOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
private readonly ICustomerService _customers;
public async ValueTask<OrderCreated> HandleAsync(CreateOrder message, CancellationToken ct) {
var customer = await _customers.GetAsync(message.CustomerId, ct);
return new OrderCreated(
message.OrderId,
message.CustomerId,
CustomerName: customer.Name, // Enrich at write time
message.Items
);
}
}
// Perspective is pure (no async)
public class OrderSummaryPerspective : IPerspectiveFor<OrderSummary, OrderCreated> {
public OrderSummary Apply(OrderSummary current, OrderCreated @event) {
return new OrderSummary {
Id = @event.OrderId,
CustomerName = @event.CustomerName // Already enriched
};
}
}
Registration Changes¶
Marten Projection Registration¶
Marten Projection Registration
services.AddMarten(opts => {
opts.Projections.Add<OrderSummaryProjection>(ProjectionLifecycle.Async);
opts.Projections.Add<CustomerOrderStatsProjection>(ProjectionLifecycle.Inline);
});
Whizbang Perspective Registration¶
Whizbang Perspective Registration
// Perspectives are auto-discovered at compile time by source generators.
// The storage chain registers every discovered perspective automatically -
// there is no per-perspective registration call and no lifecycle enum:
services
.AddWhizbang()
.WithEFCore<AppDbContext>()
.WithDriver.Postgres;
There is no equivalent of Marten's ProjectionLifecycle.Inline/Async choice: all perspectives are applied asynchronously (eventually consistent) by the perspective worker pipeline.
Testing Perspectives¶
Perspectives are easy to test because they're pure functions:
Testing Perspectives
[Test]
public async Task Apply_OrderCreated_CreatesNewSummaryAsync() {
// Arrange
var perspective = new OrderSummaryPerspective();
var @event = new OrderCreated(
TrackedGuid.NewMedo(),
CustomerId: TrackedGuid.NewMedo(),
Items: new[] { new OrderItem("SKU1", 2, 29.99m) }
);
// Act
var result = perspective.Apply(null!, @event);
// Assert
await Assert.That(result.Status).IsEqualTo(OrderStatus.Created);
await Assert.That(result.Total).IsEqualTo(59.98m);
}
[Test]
public async Task Apply_OrderShipped_UpdatesStatusAsync() {
// Arrange
var perspective = new OrderSummaryPerspective();
var current = new OrderSummary {
Id = TrackedGuid.NewMedo(),
Status = OrderStatus.Created
};
var @event = new OrderShipped(current.Id, DateTimeOffset.UtcNow);
// Act
var result = perspective.Apply(current, @event);
// Assert
await Assert.That(result.Status).IsEqualTo(OrderStatus.Shipped);
await Assert.That(result.ShippedAt).IsNotNull();
}
Migration Checklist¶
- [ ] Replace
SingleStreamProjection<T>withIPerspectiveFor<T, TEvent...> - [ ] Replace
MultiStreamProjection<T, TKey>withIGlobalPerspectiveFor<T, TKey, TEvent...> - [ ] Ensure all event types implement
IEvent(required by the interface constraints) - [ ] Convert mutation (
model.X = y) to immutable (current with { X = y }) - [ ] Move async operations to receptors
- [ ] Use
sealed recordfor model types - [ ] Add variadic event types to interface
- [ ] Implement
GetPartitionKeyfor global perspectives - [ ] Update tests to pure function assertions
Previous: Handler Migration | Next: Event Store Migration