Compose and Expose Closed Generics¶
[RegisterClosedOverImplementationsOf] lets you keep a compose-and-expose pattern on the source-generation path. For each discovered concrete closed implementation of a designated open generic interface, the generator closes a reusable composition type over the same type argument(s) and registers it as a designated facade service type — resolving the composition's constructor dependencies from DI. This is a source-generation-only feature.
Overview¶
A common pattern is an open, growing set of typed "definitions" — each a small type implementing IFooDefinition<TData> — that you want to expose uniformly behind a non-generic facade IFoo, so the rest of the system can enumerate IEnumerable<IFoo> without knowing any TData. A reusable open generic FooCore<TData> composes a definition with other per-type services (no inheritance) and implements IFoo.
Without this feature you either:
- Hand-maintain one registration per
TData— which silently drifts: add a definition and forget the matching line, and the new variant is simply absent at runtime with no compile-time signal; or - Use runtime reflection (
Assembly.GetTypes()+MakeGenericType+ActivatorUtilities) — which is AOT/trimming hostile and against the source-generation-first design.
With [RegisterClosedOverImplementationsOf], you write the marker once and adding a new feature variant means adding one definition and nothing else.
Quick Start¶
using NexusLabs.Needlr.Generators;
// Auto-discovered today: concrete, closed, unattributed.
public interface IFooDefinition<TData> where TData : class
{
string Discriminator { get; }
}
public sealed class AlphaFoo : IFooDefinition<AlphaData> { public string Discriminator => "alpha"; }
public sealed class BetaFoo : IFooDefinition<BetaData> { public string Discriminator => "beta"; }
// Non-generic facade consumed as IEnumerable<IFoo>.
public interface IFoo { string Discriminator { get; } }
// Reusable composition, closed per discovered TData and exposed as IFoo.
[RegisterClosedOverImplementationsOf(typeof(IFooDefinition<>), As = typeof(IFoo))]
public sealed class FooCore<TData> : IFoo where TData : class
{
public FooCore(IFooDefinition<TData> definition, IFooStore<TData> store) { /* ... */ }
public string Discriminator => /* delegates to definition */ "";
}
What Gets Generated¶
At compile time, Needlr discovers every concrete closed implementation of IFooDefinition<> and emits one registration per discovered type argument, resolving each constructor dependency closed over the same TData:
// Generated code (simplified)
services.AddSingleton<IFoo>(sp => new FooCore<AlphaData>(
sp.GetRequiredService<IFooDefinition<AlphaData>>(),
sp.GetRequiredService<IFooStore<AlphaData>>()));
services.AddSingleton<IFoo>(sp => new FooCore<BetaData>(
sp.GetRequiredService<IFooDefinition<BetaData>>(),
sp.GetRequiredService<IFooStore<BetaData>>()));
The generator reads the composition's constructor and resolves whatever it asks for — it does not need to know about stores, loggers, or any specific dependency. [FromKeyedServices("key")] constructor parameters are resolved as keyed services.
Lifetime¶
Each registration defaults to Singleton. Use the Lifetime property to change it:
[RegisterClosedOverImplementationsOf(typeof(IFooDefinition<>), As = typeof(IFoo), Lifetime = InjectableLifetime.Scoped)]
public sealed class FooCore<TData> : IFoo where TData : class { /* ... */ }
Multi-Parameter Generics¶
The marker takes the unbound typeof(IFooDefinition<>), and the composition is closed over the discovered type argument(s) as a list. Interfaces with multiple type parameters work the same way, provided the composition's arity matches:
public interface IPairDefinition<TKey, TValue> where TKey : class where TValue : class { }
[RegisterClosedOverImplementationsOf(typeof(IPairDefinition<,>), As = typeof(IPair))]
public sealed class PairCore<TKey, TValue> : IPair
where TKey : class where TValue : class
{
public PairCore(IPairDefinition<TKey, TValue> definition) { /* ... */ }
}
Constraint Validation¶
If a discovered type argument does not satisfy the composition type's generic constraints, the registration is skipped and the generator reports NDLRGEN038 at build time — turning a would-be runtime absence into a build-time signal.
This diagnostic is only reachable when the composition's constraints are stricter than the source interface's. If both carry the same constraint (e.g. where TData : class), every discovered implementation can always close the composition, so the diagnostic acts as a safety net for future divergence rather than something you hit day one.
What This Does and Does Not Catch¶
The feature eliminates the "I added a definition and forgot to wire it" drift entirely: valid definitions auto-wire.
It does not make the whole dependency graph statically provable. Transitive dependencies that the closed composition needs — such as an open-generic store registered in a plugin, or ILogger<> from AddLogging — are not visible to the source generator. If such a dependency is missing, resolution still throws at runtime (the same behavior as a hand-written factory). There is no regression; the failure simply remains a runtime one.
Cross-Assembly Discovery¶
A composition closes over exactly the set of definitions the generator registers — the current assembly plus any referenced plain class libraries (libraries without their own [GenerateTypeRegistry]). This is the same set [OpenDecoratorFor] expands over, so cross-assembly definitions in plain libraries are handled with no extra work.
The one boundary is Needlr's universal multi-assembly model: a referenced assembly that carries its own [GenerateTypeRegistry] self-registers its types (and is force-loaded by the consumer), so the consuming assembly's generator does not re-scan it. A marker in assembly A therefore does not reach definitions inside a self-registering referenced assembly B — B owns its own registration and can carry its own marker. Keep the marker in the same assembly as its definitions, or expose the definitions from a plain library, and discovery spans assemblies transparently.
Comparison with [OpenDecoratorFor]¶
| Feature | [OpenDecoratorFor] |
[RegisterClosedOverImplementationsOf] |
|---|---|---|
| Closes an open generic per discovered implementation | ✅ | ✅ |
| Registered service type | the same closed interface (wraps the inner) | a different facade named by As |
| Reads the closed type's constructor | ❌ (uses AddDecorator) |
✅ (emits new T(...) with DI resolution) |
| Intent | decorate / wrap | compose and expose behind a facade |
| Works with source-gen | ✅ | ✅ |
| Attribute location | NexusLabs.Needlr.Generators |
NexusLabs.Needlr.Generators |
Attribute Reference¶
| Member | Type | Default | Description |
|---|---|---|---|
SourceOpenGenericInterface (ctor) |
Type |
— | The open generic interface whose concrete closed implementations drive registration, e.g. typeof(IFooDefinition<>). |
As |
Type? |
null |
The facade service type each closed composition is registered as. Must be implemented by the composition class. |
Lifetime |
InjectableLifetime |
Singleton |
The lifetime each closed registration is given. |
AllowMultiple = true: a single composition type may carry several markers (e.g. exposing itself over multiple source interfaces or facades).
Validation¶
Needlr provides compile-time analyzers to catch configuration errors:
| Diagnostic | Description |
|---|---|
| NDLRGEN035 | Source type must be an open generic interface |
| NDLRGEN036 | Composition class must be an open generic with matching arity |
| NDLRGEN037 | Composition class must specify and implement the As service type |
| NDLRGEN038 | A discovered type argument violates the composition's constraints (registration skipped) |
Namespace Import¶
The [RegisterClosedOverImplementationsOf] attribute is in the NexusLabs.Needlr.Generators namespace:
This is intentional — it signals that the feature is source-generation-only.