Perspective Discovery¶
Verified by tests
PerspectiveDiscoveryGeneratorTests, PerspectivePurityAnalyzerTests — library CI run #31657041675 (2026-08-13)
Updated
Interface renamed in the shipped library (verified at commit 0bc6065b): perspectives implement IPerspectiveFor<TModel, TEvent1, ...> (model first, up to 20 event types) with pure Apply(TModel, TEvent) → TModel functions — not the earlier IPerspectiveOf<TEvent>/UpdateAsync design this page previously described. The framework owns persistence: your perspective never touches the database. Apply methods MUST be pure (no I/O, no side effects, deterministic) — the PerspectivePurityAnalyzer enforces this at build time (WHIZ100+ errors).
The PerspectiveDiscoveryGenerator discovers all IPerspectiveFor<TModel, TEvent...> implementations at compile-time and generates zero-reflection DI registration code. Perspectives are event-driven read models: pure functions that fold events into a denormalized model which the framework persists for you.
Perspectives vs Receptors¶
| Aspect | Perspectives | Receptors |
|---|---|---|
| Purpose | Fold events into read models | Handle commands/queries |
| Trigger | Domain events | Commands/queries |
| Signature | TModel Apply(TModel, TEvent) — pure, synchronous |
ValueTask<TResponse> HandleAsync(...) |
| Side Effects | None (enforced by analyzer) | Business logic, I/O via services |
| Persistence | Framework-owned (wh_per_* tables) |
N/A |
| Invocation | Generated perspective runners | Generated dispatcher |
Whizbang Pattern: Commands → Receptors → Events → Perspectives → Read Models
Event-Driven Read Models¶
Traditional Approach (Direct Updates)¶
Traditional Approach (Direct Updates)
// ❌ Tight coupling between command and query models
public class OrderService {
public async Task<OrderCreated> CreateOrderAsync(CreateOrder command) {
// 1. Update write model
var order = new Order(command.CustomerId, command.Items);
await _context.Orders.AddAsync(order);
// 2. Update read model (tightly coupled!)
var summary = new OrderSummary { /* ... */ };
await _context.OrderSummaries.AddAsync(summary);
await _context.SaveChangesAsync();
return new OrderCreated(/* ... */);
}
}
Whizbang Approach (Event-Driven, Pure)¶
Whizbang Approach (Event-Driven)
// ✅ Receptor publishes the event - no read-model coupling
public class CreateOrderReceptor : IReceptor<CreateOrder, OrderCreated> {
public ValueTask<OrderCreated> HandleAsync(CreateOrder message, CancellationToken ct = default) {
return ValueTask.FromResult(new OrderCreated(
OrderId: message.OrderId,
CustomerId: message.CustomerId,
Total: message.Total,
CreatedAt: DateTimeOffset.UtcNow
));
}
}
// ✅ Read model - a plain class with a [StreamId] property
public class OrderSummary {
[StreamId]
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
public decimal Total { get; set; }
public string Status { get; set; } = "";
}
// ✅ Perspective folds events into the model - PURE function, no I/O
public class OrderSummaryPerspective : IPerspectiveFor<OrderSummary, OrderCreated> {
public OrderSummary Apply(OrderSummary currentData, OrderCreated eventData) {
currentData.OrderId = eventData.OrderId;
currentData.CustomerId = eventData.CustomerId;
currentData.Total = eventData.Total;
currentData.Status = "Created";
return currentData;
}
}
Benefits: - ✅ Decoupling: Command handler doesn't know about read models - ✅ Multiple Perspectives: Many read models from same event - ✅ Deterministic Rebuild: Pure Apply functions replay identically - ✅ Framework Persistence: Storage, upserts, and concurrency handled for you
How It Works¶
1. Compile-Time Discovery¶
flowchart TD
Code["Your Code<br/><br/>public class OrderSummaryPerspective<br/>: IPerspectiveFor<OrderSummary, OrderCreated> {<br/>public OrderSummary Apply(<br/>OrderSummary currentData,<br/>OrderCreated eventData) { ... }<br/>}"]
Generator["PerspectiveDiscoveryGenerator (Roslyn)<br/><br/>1. Scan classes with base lists<br/>2. Skip abstract classes<br/>3. Match IPerspectiveFor / IPerspectiveWithActionsFor<br/>with >1 type argument<br/>4. Extract: Model type, Event types, model [StreamId]"]
Generated["Generated Code<br/><br/>PerspectiveRegistrations.g.cs<br/>— AddWhizbangPerspectives() registrations<br/>— MessageAssociation query methods"]
Code --> Generator
Generator --> Generated
class Code layer-read
class Generator layer-infrastructure
class Generated layer-core
The generator matches both IPerspectiveFor<TModel, TEvent...> and IPerspectiveWithActionsFor<TModel, TEvent...> variants; single-type-argument marker interfaces (IPerspectiveFor<TModel>) are skipped. The model type is the first type argument, and its [StreamId] property is located for stream addressing.
2. Generated File¶
PerspectiveRegistrations.g.cs (emitted into {AssemblyName}.Generated):
Generated File
public static class PerspectiveRegistrationExtensions {
/// <summary>
/// Registers all discovered IPerspectiveFor implementations (Scoped).
/// Returns a WhizbangPerspectiveBuilder for storage-provider configuration.
/// </summary>
public static WhizbangPerspectiveBuilder AddWhizbangPerspectives(this IServiceCollection services) {
// Serializes Apply calls per (streamId, perspectiveName)
services.TryAddSingleton<IPerspectiveApplyCoordinator, PerspectiveApplyCoordinator>();
// One registration per perspective class, with its FULL interface signature:
services.AddScoped<IPerspectiveFor<OrderSummary, OrderCreated, OrderShipped>, OrderSummaryPerspective>();
services.AddScoped<IPerspectiveFor<CustomerStats, OrderCreated>, CustomerStatisticsPerspective>();
return new WhizbangPerspectiveBuilder(services);
}
// Event ↔ perspective association queries (used by tooling and sync infrastructure)
public static IReadOnlyList<MessageAssociation> GetMessageAssociations(string serviceName);
public static IEnumerable<string> GetPerspectivesForEvent(string eventType, string serviceName);
public static IEnumerable<string> GetEventsForPerspective(string perspectiveName, string serviceName);
// + overloads with MatchStrictness fuzzy matching and Regex patterns
}
The file also declares two records used by the association queries:
Association Records
public sealed record MessageAssociation(
string MessageType, // "MyApp.Events.OrderCreated, MyApp"
string AssociationType, // "perspective"
string TargetName, // "OrderSummaryPerspective"
string ServiceName // assembly name
);
public sealed record PerspectiveAssociationInfo<TModel, TEvent>(
string MessageType,
string TargetName,
string ServiceName,
Func<TModel, TEvent, TModel> ApplyDelegate // strongly-typed, AOT-compatible
) where TEvent : IEvent;
Key Observations:
- One perspective class can handle multiple events (one interface with up to 20 event type arguments)
- Multiple perspectives can handle the same event
- Registered as Scoped to match typical database-context lifetime
- AddWhizbangPerspectives() returns a WhizbangPerspectiveBuilder so you can chain storage-provider configuration
Note that PerspectiveDiscoveryGenerator produces the DI registrations and association metadata; the sibling PerspectiveRunnerGenerator/PerspectiveInvokerGenerator generate the runners that actually route stored events into your Apply methods.
Perspective Patterns¶
Pattern 1: Single Event¶
Pattern 1: Single Event
public class OrderSummaryPerspective : IPerspectiveFor<OrderSummary, OrderCreated> {
public OrderSummary Apply(OrderSummary currentData, OrderCreated eventData) {
currentData.OrderId = eventData.OrderId;
currentData.Status = "Created";
return currentData;
}
}
Generated registration: Pattern 1 Registration
Pattern 2: Multiple Events¶
Pattern 2: Multiple Events
public class OrderSummaryPerspective :
IPerspectiveFor<OrderSummary, OrderCreated, OrderShipped, OrderCancelled> {
public OrderSummary Apply(OrderSummary currentData, OrderCreated eventData) {
currentData.OrderId = eventData.OrderId;
currentData.Status = "Created";
return currentData;
}
public OrderSummary Apply(OrderSummary currentData, OrderShipped eventData) {
currentData.Status = "Shipped";
currentData.ShippedAt = eventData.ShippedAt; // Use event time, never DateTime.UtcNow!
return currentData;
}
public OrderSummary Apply(OrderSummary currentData, OrderCancelled eventData) {
currentData.Status = "Cancelled";
return currentData;
}
}
Pattern 3: Aggregated Statistics¶
Pattern 3: Aggregated Statistics
public class CustomerStatisticsPerspective :
IPerspectiveFor<CustomerStats, OrderCreated, OrderShipped> {
public CustomerStats Apply(CustomerStats currentData, OrderCreated eventData) {
currentData.TotalOrders += 1;
currentData.TotalSpent += eventData.Total;
currentData.LastOrderAt = eventData.CreatedAt;
return currentData;
}
public CustomerStats Apply(CustomerStats currentData, OrderShipped eventData) {
currentData.LastShippedAt = eventData.ShippedAt;
return currentData;
}
}
Use Case: Pre-computed aggregations for analytics dashboards — the framework upserts the folded model.
Purity Enforcement¶
Apply methods must be pure functions. The PerspectivePurityAnalyzer reports build errors for violations (WHIZ100-range diagnostics):
| ID | Violation |
|---|---|
| WHIZ100 | Apply method returns Task (must be synchronous) |
| WHIZ101 | Apply method uses async/await |
| WHIZ102 | Apply method performs database I/O |
| WHIZ103 | Apply method performs HTTP/network calls |
Practical rules:
- No DbContext, IDbConnection, HTTP clients, or file I/O inside Apply
- Use event timestamps, never DateTime.UtcNow
- Return the updated model; the framework persists it
Diagnostics¶
WHIZ007: Perspective Discovered¶
Severity: Info
Message: Found perspective '{0}' listening to {1}
Example:
info WHIZ007: Found perspective 'OrderSummaryPerspective' listening to OrderCreated, OrderShipped, OrderCancelled
WHIZ030: Perspective Event Missing StreamId¶
Severity: Error
Message: Event type '{0}' used in perspective '{1}' must have exactly one property marked with [StreamId] attribute
Every event a perspective listens to must carry a [StreamId] so the framework can address the model's stream.
WHIZ031: Multiple StreamId Attributes¶
Severity: Error
Message: Event type '{0}' has multiple properties marked with [StreamId]. Only one property can be the stream ID.
Related model-side diagnostics from the runner generators: WHIZ033 (Warning) — a perspective's model without a [StreamId] property will not get a generated runner.
AOT Compatibility¶
Generated registration uses no reflection:
Zero Reflection Guarantee
// ✅ Direct type registration (AOT-compatible)
services.AddScoped<IPerspectiveFor<OrderSummary, OrderCreated>, OrderSummaryPerspective>();
// ❌ Reflection-based registration (incompatible with AOT)
var perspectiveType = typeof(IPerspectiveFor<,>).MakeGenericType(modelType, eventType);
var implementationType = assembly.GetTypes().First(t => t.IsAssignableTo(perspectiveType));
services.AddScoped(perspectiveType, implementationType);
The PerspectiveAssociationInfo<TModel, TEvent>.ApplyDelegate gives strongly-typed, delegate-based Apply invocation with zero reflection.
Debugging Generated Code¶
View Generated File¶
With the shipped defaults (WhizbangEmitMessageRegistry=true sets EmitCompilerGeneratedFiles and points output at the ignored cache folder):
.whizbang/cache/Whizbang.Generators/Whizbang.Generators.PerspectiveDiscoveryGenerator/
└── PerspectiveRegistrations.g.cs
Or configure explicitly: View Generated File
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>.whizbang/cache</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
Best Practices¶
DO ✅¶
- ✅ Implement
IPerspectiveFor<TModel, TEvent...>with one Apply per event type - ✅ Keep Apply pure — no I/O, no side effects, deterministic
- ✅ Use event timestamps for time values
- ✅ Put
[StreamId]on the model and on every event the perspective listens to - ✅ Group related events in one perspective class
- ✅ Call
AddWhizbangPerspectives()and chain a storage provider on the returned builder
DON'T ❌¶
- ❌ Perform database or HTTP calls in Apply (build error via purity analyzer)
- ❌ Use
asyncApply methods (build error) - ❌ Use
DateTime.UtcNowin Apply (breaks deterministic replay) - ❌ Put business logic in perspectives (belongs in receptors)
- ❌ Manually register perspectives (generator handles this)
- ❌ Modify generated files (regenerated every build)
Troubleshooting¶
Problem: Perspective Not Invoked¶
Symptoms: Event published but the perspective's model never updates.
Causes:
1. Class doesn't implement an IPerspectiveFor<TModel, TEvent...> variant with the event listed
2. Missing AddWhizbangPerspectives() call (or no storage provider configured)
3. Event type missing [StreamId] (WHIZ030 build error)
Problem: Generator Doesn't Find Perspectives¶
Symptoms: PerspectiveRegistrations.g.cs has no registrations.
Causes: 1. No concrete classes implement a perspective interface with >1 type argument 2. Perspective classes are abstract (skipped — they can't be instantiated)
Problem: Generator Doesn't Find Perspectives
// ✅ Concrete class - discovered
public class OrderSummaryPerspective : IPerspectiveFor<OrderSummary, OrderCreated> { /* ... */ }
// ❌ Abstract class - skipped by generator
public abstract class BasePerspective : IPerspectiveFor<OrderSummary, OrderCreated> { /* ... */ }
Problem: Build Error WHIZ100-WHIZ103¶
Symptoms: Purity analyzer errors on Apply methods.
Solution: Remove I/O and async code from Apply. Anything requiring services or awaits belongs in a receptor; Apply only folds the event into the model.
Further Reading¶
Source Generators: - Receptor Discovery - Compile-time receptor discovery - Message Registry - VSCode extension integration - Aggregate IDs - [StreamId] discovery and extraction - JSON Contexts - AOT-compatible JSON serialization - Configuration - Perspective table naming configuration
Core Concepts: - Perspectives - Event-driven read models - Lenses - Query-optimized repositories
Data Access: - Perspectives Storage - Read model schema design - Event Store - Event sourcing and replay
Workers: - Perspective Worker - Processing lifecycle and runtime behavior - Execution Lifecycle - Startup/shutdown coordination
Version 1.0.0 - Foundation Release | Last Updated: 2026-07-16