Skip to content

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 implement IEvent interface

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 - uses new keyword
  • ✅ 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

  1. Cache associations in hot paths - GetPerspectiveAssociations is fast but not free
  2. Use for generic perspective invocation - Ideal for frameworks and libraries
  3. Prefer over reflection - Delegates are much faster than MethodInfo.Invoke
  4. Combine with MessageAssociation - Use MessageAssociation for discovery, PerspectiveAssociationInfo for invocation
  5. Handle empty results - Check if list is empty before invoking
  6. Use in AOT scenarios - Fully trim-safe and AOT-compatible
  7. 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