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<IStreamIdExtractor>.Register(extractor, 100)"]
D1["MyApp.Services.dll loads"]
D2["[ModuleInitializer] runs"]
D3["AssemblyRegistry<IStreamIdExtractor>.Register(extractor, 1000)"]
M["3. Main() executes"]
M1["AddWhizbang() called"]
M2["Uses AssemblyRegistry<T>.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
Thas 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
Related Documentation¶
- Delivery Receipts - StreamIdExtractorRegistry usage
- AOT Requirements - AOT compatibility patterns
- Source Generators - Generated registration code
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