Skip to content

Assembly Registry

Verified by tests

AssemblyRegistryTests, StreamIdExtractorRegistryTests — library CI run #31657041675 (2026-08-13)

The AssemblyRegistry<T> is a generic, thread-safe registry for multi-assembly contributions. It enables assemblies to self-register components at load time using [ModuleInitializer].

Overview

When types are defined in a "contracts" assembly but used in a "service" assembly, the service's generated code doesn't know about the contracts' types. The registry solves this:

  • Self-registration: Assemblies register contributions via [ModuleInitializer]
  • Priority-based: Lower priority = tried first
  • Thread-safe: Supports concurrent registration and access
  • AOT compatible: No runtime reflection for discovery

AssemblyRegistry

AssemblyRegistry

namespace Whizbang.Core.Registry;

/// <summary>
/// Generic thread-safe registry for multi-assembly contributions.
/// Uses [ModuleInitializer] pattern - assemblies self-register at load time.
/// </summary>
/// <typeparam name="T">The contribution type (e.g., IStreamIdExtractor)</typeparam>
/// <remarks>
/// <strong>How it works:</strong>
/// <list type="number">
/// <item>Each assembly's [ModuleInitializer] registers its contributions at load time</item>
/// <item>Contributions are stored with priority (lower = tried first)</item>
/// <item>Consumers retrieve all contributions ordered by priority</item>
/// </list>
///
/// <strong>Priority convention:</strong>
/// <list type="bullet">
/// <item>100 = Contracts assemblies (tried first)</item>
/// <item>1000 = Service assemblies (default)</item>
/// </list>
/// </remarks>
public static class AssemblyRegistry<T> where T : class {
  /// <summary>
  /// Register a contribution. Called from [ModuleInitializer] - runs before Main().
  /// Throws ArgumentNullException when contribution is null.
  /// </summary>
  /// <param name="contribution">The contribution to register</param>
  /// <param name="priority">Lower = tried first. Contracts use 100, services use 1000.</param>
  public static void Register(T contribution, int priority = 1000);

  /// <summary>
  /// Get all contributions ordered by priority (lower first).
  /// </summary>
  public static IReadOnlyList<T> GetOrderedContributions();

  /// <summary>
  /// Count of registered contributions (for diagnostics/testing).
  /// </summary>
  public static int Count { get; }
}

Registration Flow

graph TB
    A["1. Application starts"]
    B["2. CLR loads assemblies"]
    C1["MyApp.Contracts.dll loads"]
    C2["[ModuleInitializer] runs BEFORE Main()"]
    C3["AssemblyRegistry&lt;IStreamIdExtractor&gt;.Register(extractor, 100)"]
    D1["MyApp.Services.dll loads"]
    D2["[ModuleInitializer] runs"]
    D3["AssemblyRegistry&lt;IStreamIdExtractor&gt;.Register(extractor, 1000)"]
    M["3. Main() executes"]
    M1["AddWhizbang() called"]
    M2["Uses AssemblyRegistry&lt;T&gt;.GetOrderedContributions()"]
    M3["Gets: [ContractsExtractor (100), ServiceExtractor (1000)]"]

    A --> B
    B --> C1 --> C2 --> C3
    B --> D1 --> D2 --> D3
    C3 --> M
    D3 --> M
    M --> M1 --> M2 --> M3

    style A fill:#d4edda,stroke:#28a745
    style B fill:#d4edda,stroke:#28a745
    style M fill:#d4edda,stroke:#28a745

Priority Convention

Priority Assembly Type Description
100 Contracts Shared DTOs, interfaces - tried first
500 Shared libraries Cross-cutting concerns
1000 Services (default) Application services
2000+ Fallback Last-resort implementations

Lower priority = higher precedence.

Usage Examples

Registering via ModuleInitializer

Registering via ModuleInitializer

// In MyApp.Contracts assembly
namespace MyApp.Contracts;

internal static class ModuleInit {
  [ModuleInitializer]
  public static void Initialize() {
    // Register stream ID extractor with high priority (contracts assembly)
    AssemblyRegistry<IStreamIdExtractor>.Register(
        new GeneratedStreamIdExtractor(),
        priority: 100);

    // Register JSON serialization context
    AssemblyRegistry<JsonSerializerContext>.Register(
        MyAppContractsJsonContext.Default,
        priority: 100);
  }
}

Generated Code Example

Source generators produce registration code:

Generated Code Example

// Generated by Whizbang.Generators
namespace MyApp.Contracts.Generated;

internal static class StreamIdExtractorRegistration {
  [ModuleInitializer]
  public static void Register() {
    Whizbang.Core.Registry.StreamIdExtractorRegistry.Register(
        new GeneratedStreamIdExtractor(),
        priority: 100);
  }
}

Consuming Registered Contributions

Consuming Registered Contributions

// In service configuration
public static class ServiceConfiguration {
  public static IServiceCollection AddMyServices(
      this IServiceCollection services) {

    // Get all registered extractors
    var extractors = AssemblyRegistry<IStreamIdExtractor>
        .GetOrderedContributions();

    // Use composite pattern
    services.AddSingleton<IStreamIdExtractor>(
        new CompositeStreamIdExtractor(extractors));

    return services;
  }
}

Creating a Domain-Specific Registry

Creating a Domain-Specific Registry

// Wrapper for type-specific registry
public static class MyFeatureRegistry {
  public static void Register(IMyFeatureProvider provider, int priority = 1000) {
    AssemblyRegistry<IMyFeatureProvider>.Register(provider, priority);
  }

  public static IReadOnlyList<IMyFeatureProvider> GetProviders() {
    return AssemblyRegistry<IMyFeatureProvider>.GetOrderedContributions();
  }

  public static IMyFeatureProvider GetComposite() =>
      new CompositeMyFeatureProvider(GetProviders());
}

Existing Domain Registries

Whizbang includes several domain-specific registries:

StreamIdExtractorRegistry

StreamIdExtractorRegistry

// Extracts stream IDs from messages
StreamIdExtractorRegistry.Register(extractor, priority: 100);
var streamId = StreamIdExtractorRegistry.ExtractStreamId(message, type);

JsonContextRegistry

JsonContextRegistry

// Registers JSON serialization contexts (IJsonTypeInfoResolver) for AOT
JsonContextRegistry.RegisterContext(MyJsonContext.Default, priority: 100);

// Combined options resolve types across all registered contexts
var options = JsonContextRegistry.CreateCombinedOptions();

// Name-based lookup (used for stored event type names)
var typeInfo = JsonContextRegistry.GetTypeInfoByName(
    "MyApp.Events.OrderCreated, MyApp", options);

Thread Safety

The registry uses ConcurrentBag plus a lock-protected ordered cache (invalidated on each registration and rebuilt from a snapshot to avoid racing concurrent Register calls):

Thread Safety

public static class AssemblyRegistry<T> where T : class {
  private static readonly ConcurrentBag<(int Priority, T Contribution)> _contributions = [];
  private static List<T>? _orderedContributions;  // Cached
  private static readonly Lock _lock = new();

  public static void Register(T contribution, int priority = 1000) {
    ArgumentNullException.ThrowIfNull(contribution);
    _contributions.Add((priority, contribution));
    lock (_lock) {
      _orderedContributions = null;  // Invalidate cache
    }
  }

  public static IReadOnlyList<T> GetOrderedContributions() {
    if (_orderedContributions is not null) return _orderedContributions;

    lock (_lock) {
      if (_orderedContributions is not null) return _orderedContributions;
      // Snapshot before iterating to avoid racing concurrent Register() calls
      _orderedContributions = [.. _contributions
          .ToArray()
          .OrderBy(c => c.Priority)
          .Select(c => c.Contribution)];
      return _orderedContributions;
    }
  }
}

Testing

Priority ordering is easy to verify in tests:

Testing

[Test]
public async Task Registry_WithMultiplePriorities_OrdersByPriorityAsync() {
  // Arrange
  AssemblyRegistry<IMyProvider>.Register(new ProviderA(), priority: 500);
  AssemblyRegistry<IMyProvider>.Register(new ProviderB(), priority: 100);
  AssemblyRegistry<IMyProvider>.Register(new ProviderC(), priority: 1000);

  // Act
  var providers = AssemblyRegistry<IMyProvider>.GetOrderedContributions();

  // Assert
  await Assert.That(providers[0]).IsTypeOf<ProviderB>();  // 100
  await Assert.That(providers[1]).IsTypeOf<ProviderA>();  // 500
  await Assert.That(providers[2]).IsTypeOf<ProviderC>();  // 1000
}

Updated

The registry also has a ClearForTesting() reset method, but it is internal — it is only reachable from Whizbang's own test assemblies (via InternalsVisibleTo), not from consumer test code. In consumer tests, prefer registering distinct contribution types per test (each generic T gets its own isolated registry) rather than trying to reset shared state.

Best Practices

DO

  • Use ModuleInitializer for automatic registration
  • Use priority 100 for contracts assemblies
  • Use priority 1000 (default) for service assemblies
  • Create domain-specific wrappers for clarity
  • Use per-test contribution types for isolation (each generic T has its own registry; the reset method is internal-only)

DON'T

  • Don't call Register from Main() - too late for some scenarios
  • Don't use priority 0 - reserve headroom for future needs
  • Don't rely on registration order - only priority matters
  • Don't modify contributions after retrieval

For Contributors

Looking to understand how source generators configure assembly registration? See: - Source Generator Configuration — Configure source generator behavior and assembly-level registration


Version 1.0.0 - Foundation Release