PerspectiveAssociationInfo: Strongly-Typed Associations with Delegates¶
Verified by tests
PerspectiveDiscoveryGeneratorTests — library CI run #31657041675 (2026-08-13)
PerspectiveAssociationInfo is a generic record type that provides strongly-typed perspective associations with AOT-compatible delegates to perspective Apply methods. It enables compile-time type safety and performant perspective invocation without reflection.
Updated
This page covers the generic PerspectiveAssociationInfo<TModel, TEvent> record used for runtime invocation of perspective Apply methods via delegates. For the non-generic PerspectiveAssociationInfo metadata class used by source generators for compile-time discovery and registration, see Perspective Association Info (Metadata).
Overview¶
PerspectiveAssociationInfo<TModel, TEvent> provides: - ✅ Strongly-typed delegate access to perspective Apply methods - ✅ Compile-time type safety (no runtime type checks) - ✅ AOT-compatible (zero reflection at runtime) - ✅ Generated by source generators for all perspectives - ✅ Supports generic perspective invocation
Quick Start¶
Getting Typed Associations¶
Getting Typed Associations
using Whizbang.Core.Generated;
// Get associations for specific model and event types
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>("ECommerce.BFF.API");
foreach (var assoc in associations) {
Console.WriteLine($"Perspective: {assoc.TargetName}");
Console.WriteLine($"Event: {assoc.MessageType}");
Console.WriteLine($"Service: {assoc.ServiceName}");
// Invoke delegate directly
var currentModel = new ProductModel();
var evt = new ProductCreatedEvent { ProductId = "prod-123" };
var updatedModel = assoc.ApplyDelegate(currentModel, evt);
}
Using Delegates¶
Using Delegates
// Example: Generic perspective applier
public TModel ApplyEvent<TModel, TEvent>(
TModel model,
TEvent evt,
string serviceName)
where TEvent : IEvent {
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<TModel, TEvent>(serviceName);
// Apply all matching perspectives
foreach (var assoc in associations) {
model = assoc.ApplyDelegate(model, evt);
}
return model;
}
// Usage
var productModel = new ProductModel();
var productEvent = new ProductCreatedEvent { ProductId = "prod-123" };
var updated = ApplyEvent(productModel, productEvent, "ECommerce.BFF.API");
Record Structure¶
Type Definition¶
Type Definition
/// <summary>
/// Rich association info with strongly-typed delegate for perspective Apply method.
/// Provides compile-time type safety and AOT-compatible delegate invocation.
/// </summary>
/// <typeparam name="TModel">The model type maintained by the perspective</typeparam>
/// <typeparam name="TEvent">The event type handled by the perspective</typeparam>
/// <param name="MessageType">Fully qualified event type name</param>
/// <param name="TargetName">Name of the perspective class</param>
/// <param name="ServiceName">Service name (assembly name)</param>
/// <param name="ApplyDelegate">Strongly-typed delegate to perspective's Apply method</param>
public sealed record PerspectiveAssociationInfo<TModel, TEvent>(
string MessageType,
string TargetName,
string ServiceName,
Func<TModel, TEvent, TModel> ApplyDelegate
) where TEvent : IEvent;
Properties¶
| Property | Type | Description |
|---|---|---|
MessageType |
string |
Fully qualified event type name (e.g., "ECommerce.Contracts.Events.ProductCreatedEvent") |
TargetName |
string |
Simple name of the perspective class (e.g., "ProductPerspective") |
ServiceName |
string |
Service name / assembly name (e.g., "ECommerce.BFF.API") |
ApplyDelegate |
Func<TModel, TEvent, TModel> |
Strongly-typed delegate invoking the perspective's Apply method |
Type Constraints¶
TModel: No constraints (any type)TEvent: Must implementIEventinterface
Delegate Invocation¶
Direct Invocation¶
Direct Invocation
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<InventoryModel, ProductCreatedEvent>("ECommerce.BFF.API");
var model = new InventoryModel { ProductCount = 10 };
var evt = new ProductCreatedEvent { ProductId = "prod-123" };
foreach (var assoc in associations) {
// Direct delegate invocation
model = assoc.ApplyDelegate(model, evt);
Console.WriteLine($"Applied {assoc.TargetName}: ProductCount = {model.ProductCount}");
}
Generic Invocation Helper¶
Generic Invocation Helper
public class PerspectiveApplier {
private readonly string _serviceName;
public PerspectiveApplier(string serviceName) {
_serviceName = serviceName;
}
public TModel ApplyAll<TModel, TEvent>(TModel model, TEvent evt)
where TEvent : IEvent {
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<TModel, TEvent>(_serviceName);
foreach (var assoc in associations) {
model = assoc.ApplyDelegate(model, evt);
}
return model;
}
}
// Usage
var applier = new PerspectiveApplier("ECommerce.BFF.API");
var updatedModel = applier.ApplyAll(currentModel, newEvent);
Common Scenarios¶
Scenario 1: Generic Perspective Runner¶
When: Building a generic perspective materialization engine
Scenario 1: Generic Perspective Runner
public class PerspectiveMaterializer {
public async Task<TModel> MaterializeAsync<TModel, TEvent>(
TModel initialModel,
IEnumerable<TEvent> events,
string serviceName)
where TEvent : IEvent {
// Get associations once (outside loop for performance)
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<TModel, TEvent>(serviceName);
if (!associations.Any()) {
throw new InvalidOperationException(
$"No perspectives found for {typeof(TModel).Name} handling {typeof(TEvent).Name}"
);
}
var model = initialModel;
// Apply each event through all perspectives
foreach (var evt in events) {
foreach (var assoc in associations) {
model = assoc.ApplyDelegate(model, evt);
}
}
return model;
}
}
// Usage
var materializer = new PerspectiveMaterializer();
var events = await LoadEventsAsync();
var model = await materializer.MaterializeAsync(
new ProductModel(),
events,
"ECommerce.BFF.API"
);
Scenario 2: Perspective Discovery and Diagnostics¶
When: Discovering available perspectives at runtime
Scenario 2: Perspective Discovery and Diagnostics
public class PerspectiveDiagnostics {
public void PrintPerspectiveInfo<TModel, TEvent>(string serviceName)
where TEvent : IEvent {
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<TModel, TEvent>(serviceName);
Console.WriteLine($"Perspectives handling {typeof(TEvent).Name}:");
Console.WriteLine($"Model Type: {typeof(TModel).FullName}");
Console.WriteLine($"Event Type: {typeof(TEvent).FullName}");
Console.WriteLine();
foreach (var assoc in associations) {
Console.WriteLine($" Perspective: {assoc.TargetName}");
Console.WriteLine($" Message Type: {assoc.MessageType}");
Console.WriteLine($" Service: {assoc.ServiceName}");
Console.WriteLine($" Delegate: {assoc.ApplyDelegate.Method.Name}");
Console.WriteLine();
}
}
}
// Usage
var diagnostics = new PerspectiveDiagnostics();
diagnostics.PrintPerspectiveInfo<ProductModel, ProductCreatedEvent>("ECommerce.BFF.API");
// Output:
// Perspectives handling ProductCreatedEvent:
// Model Type: ECommerce.BFF.API.Models.ProductModel
// Event Type: ECommerce.Contracts.Events.ProductCreatedEvent
//
// Perspective: ProductPerspective
// Message Type: ECommerce.Contracts.Events.ProductCreatedEvent
// Service: ECommerce.BFF.API
// Delegate: <lambda_method>
Scenario 3: Testing Perspective Behavior¶
When: Unit testing perspectives in isolation
Scenario 3: Testing Perspective Behavior
[Test]
public async Task ProductPerspective_ApplyProductCreatedEvent_IncrementsCountAsync() {
// Arrange
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>("ECommerce.BFF.API");
var assoc = associations.Single(a => a.TargetName == "ProductPerspective");
var initialModel = new ProductModel { ProductCount = 5 };
var evt = new ProductCreatedEvent { ProductId = "prod-123" };
// Act
var updatedModel = assoc.ApplyDelegate(initialModel, evt);
// Assert
await Assert.That(updatedModel.ProductCount).IsEqualTo(6);
}
Scenario 4: Performance Optimization with Caching¶
When: Caching associations for high-throughput scenarios
Scenario 4: Performance Optimization with Caching
public class CachedPerspectiveApplier {
private readonly ConcurrentDictionary<Type, object> _associationCache = new();
private readonly string _serviceName;
public CachedPerspectiveApplier(string serviceName) {
_serviceName = serviceName;
}
public TModel ApplyEvent<TModel, TEvent>(TModel model, TEvent evt)
where TEvent : IEvent {
// Get from cache or create
var cacheKey = typeof((TModel, TEvent));
var associations = (IReadOnlyList<PerspectiveAssociationInfo<TModel, TEvent>>)
_associationCache.GetOrAdd(cacheKey, _ =>
PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<TModel, TEvent>(_serviceName)
);
// Apply all perspectives
foreach (var assoc in associations) {
model = assoc.ApplyDelegate(model, evt);
}
return model;
}
}
// Usage - associations cached after first call
var applier = new CachedPerspectiveApplier("ECommerce.BFF.API");
for (int i = 0; i < 10000; i++) {
model = applier.ApplyEvent(model, events[i]); // Fast after first call
}
AOT Compatibility¶
No Reflection at Runtime¶
PerspectiveAssociationInfo delegates are generated at compile time:
No Reflection at Runtime
// Generated code (example)
public static IReadOnlyList<PerspectiveAssociationInfo<TModel, TEvent>>
GetPerspectiveAssociations<TModel, TEvent>(string serviceName)
where TEvent : IEvent {
// Compile-time type checking
if (typeof(TModel) == typeof(ProductModel) &&
typeof(TEvent) == typeof(ProductCreatedEvent)) {
return new[] {
new PerspectiveAssociationInfo<TModel, TEvent>(
"ECommerce.Contracts.Events.ProductCreatedEvent",
"ProductPerspective",
"ECommerce.BFF.API",
(model, evt) => {
// Direct instantiation - no reflection!
var perspective = new ProductPerspective();
var typedModel = (ProductModel)((object)model);
var typedEvent = (ProductCreatedEvent)((object)evt);
var result = perspective.Apply(typedModel, typedEvent);
return (TModel)((object)result);
}
)
};
}
return Array.Empty<PerspectiveAssociationInfo<TModel, TEvent>>();
}
Key AOT Features¶
- ✅ No
Activator.CreateInstance- usesnewkeyword - ✅ No
MethodInfo.Invoke- uses direct method calls - ✅ No reflection-based type discovery - uses
typeof()checks - ✅ All type checking at compile time
- ✅ Trim-safe (no dynamic type loading)
Integration with MessageAssociation¶
PerspectiveAssociationInfo complements the simpler MessageAssociation type:
Integration with MessageAssociation
// MessageAssociation: Simple string-based association
public sealed record MessageAssociation(
string MessageType,
string AssociationType,
string TargetName,
string ServiceName
);
// PerspectiveAssociationInfo: Strongly-typed with delegates
public sealed record PerspectiveAssociationInfo<TModel, TEvent>(
string MessageType,
string TargetName,
string ServiceName,
Func<TModel, TEvent, TModel> ApplyDelegate
) where TEvent : IEvent;
Use MessageAssociation when: - Querying available associations - String-based type matching - Discovery and metadata
Use PerspectiveAssociationInfo when: - Invoking perspectives - Generic perspective handling - Performance-critical code paths - Type-safe operations
Converting Between Types¶
Converting Between Types
// Get MessageAssociations (all perspectives)
var messageAssocs = PerspectiveRegistrationExtensions
.GetMessageAssociations("ECommerce.BFF.API");
// Filter to specific event
var productAssocs = messageAssocs
.Where(a => a.MessageType.Contains("ProductCreatedEvent"));
// Get typed associations for invocation
var typedAssocs = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>("ECommerce.BFF.API");
// Now can invoke via delegates
foreach (var assoc in typedAssocs) {
model = assoc.ApplyDelegate(model, evt);
}
API Reference¶
Method Signature¶
Namespace: Whizbang.Core.Generated
Method Signature
public static IReadOnlyList<PerspectiveAssociationInfo<TModel, TEvent>>
GetPerspectiveAssociations<TModel, TEvent>(string serviceName)
where TEvent : IEvent;
Parameters¶
- serviceName: Service name / assembly name (e.g., "ECommerce.BFF.API")
Type Parameters¶
- TModel: Model type maintained by the perspective
- TEvent: Event type handled by the perspective (must implement IEvent)
Return Value¶
- Returns
IReadOnlyList<PerspectiveAssociationInfo<TModel, TEvent>> - Returns empty list if no matching perspectives found
- Never returns null
Best Practices¶
- Cache associations in hot paths -
GetPerspectiveAssociationsis fast but not free - Use for generic perspective invocation - Ideal for frameworks and libraries
- Prefer over reflection - Delegates are much faster than
MethodInfo.Invoke - Combine with MessageAssociation - Use MessageAssociation for discovery, PerspectiveAssociationInfo for invocation
- Handle empty results - Check if list is empty before invoking
- Use in AOT scenarios - Fully trim-safe and AOT-compatible
- Leverage compile-time type safety - Compiler enforces TModel and TEvent constraints
Common Pitfalls¶
❌ Forgetting to Check for Empty Results¶
❌ Forgetting to Check for Empty Results
// ❌ WRONG: Assuming associations exist
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>(serviceName);
var assoc = associations.First(); // May throw if empty!
// ✅ CORRECT: Check for empty
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>(serviceName);
if (!associations.Any()) {
throw new InvalidOperationException("No perspectives found");
}
❌ Not Caching in Loops¶
❌ Not Caching in Loops
// ❌ WRONG: Getting associations in loop
foreach (var evt in events) {
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>(serviceName);
// ... apply
}
// ✅ CORRECT: Get associations once
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>(serviceName);
foreach (var evt in events) {
foreach (var assoc in associations) {
model = assoc.ApplyDelegate(model, evt);
}
}
❌ Mixing Type Parameters¶
❌ Mixing Type Parameters
// ❌ WRONG: Mismatched types
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, OrderCreatedEvent>(serviceName);
var productEvent = new ProductCreatedEvent();
var assoc = associations.First();
assoc.ApplyDelegate(model, productEvent); // Compile error!
// ✅ CORRECT: Matching types
var associations = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>(serviceName);
var productEvent = new ProductCreatedEvent();
var assoc = associations.First();
assoc.ApplyDelegate(model, productEvent); // Works!
Performance Considerations¶
Delegate Invocation Cost¶
Delegate Invocation Cost
// Delegate invocation: ~1-2ns per call (very fast)
var model = assoc.ApplyDelegate(currentModel, evt);
// Compare to reflection: ~100-1000ns per call
// Delegate invocation is 50-500x faster than reflection!
Caching Strategy¶
Caching Strategy
// For high-throughput scenarios, cache associations
private readonly IReadOnlyList<PerspectiveAssociationInfo<ProductModel, ProductCreatedEvent>> _cached;
public MyClass(string serviceName) {
_cached = PerspectiveRegistrationExtensions
.GetPerspectiveAssociations<ProductModel, ProductCreatedEvent>(serviceName);
}
public ProductModel ApplyEvent(ProductModel model, ProductCreatedEvent evt) {
foreach (var assoc in _cached) {
model = assoc.ApplyDelegate(model, evt);
}
return model;
}
See Also¶
- Perspectives - Overview of perspective system
- Typed Associations - GetPerspectiveAssociations method details
- Message Associations - String-based associations
- AOT Compatibility - Native AOT support