feat: support open generic registration#41
Conversation
An open generic implementation can now be registered once with the non-generic Type-ctor form - [Transient(typeof(Repository<>), typeof(IRepository<>))] (and the Singleton/Scoped equivalents, plus a one-argument self form [Transient(typeof(Repository<>))]) - and every closed form is constructed on demand: resolving IRepository<Order> builds Repository<Order>, IRepository<Customer> builds Repository<Customer>, and so on. An unbound generic cannot be a type argument, so these coexist with the existing generic attribute forms rather than replacing them. Open registrations are collected apart from concrete ones: they are templates, not instances. A new ExpandOpenGenerics worklist pass seeds from every closed generic service that an already-known implementation's constructor requires - unwrapping the Func<T>, Lazy<T> and collection shapes so a closed generic reached through a relationship or an IEnumerable<T> still seeds expansion - and, when its open form matches an open registration and it has no concrete registration, constructs the matching closed implementation through Roslyn symbol construction and synthesizes a concrete closed RawRegistration. The synthesized implementation's own constructor may reference further closed generics, so the worklist iterates to a fixpoint. The synthesized closed registrations are ordinary registrations from coalescing onward, so the existing emission, dispatch, lifetime, disposal, cycle and captive machinery handles them unchanged with no Emitter changes - a singleton open registration shares one closed instance per type argument, a transient one is fresh, and a closed instance participates in AWT102/AWT103 like any other service. Two diagnostics guard the open form. AWT125 reports an implementation/service arity mismatch (Repository<,> declared for IRepository<>) where no closed service can be mapped onto the implementation's type parameters; v1 matches the open form exactly, so the arities must be equal. AWT126 reports a required closed type whose type arguments violate the implementation's type-parameter constraints (Repository<int> against where T : class), checking the reference, value, unmanaged, new() and declared base/interface constraints. Variance is out of scope for v1. AWK->AWT mapping: prototype AWK115->AWT125 (arity), AWK116->AWT126 (constraint). Adds runtime behavior tests across all target frameworks, source-generator tests for the emitted closed resolver and dispatch, AWT125 and AWT126, and updates the public-API approval baselines for the three new non-generic Type-ctor attributes.
A collection of a closed generic service - IEnumerable<IHandler<OrderPlaced>>, and the IReadOnlyList<T>/IReadOnlyCollection<T>/IList<T>/ICollection<T>/T[] shapes - now resolves to every open-generic registration expanded at that closed type argument. Registering [Transient(typeof(AuditHandler<>), typeof(IHandler<>))] and [Transient(typeof(ProjectionHandler<>), typeof(IHandler<>))] and injecting IEnumerable<IHandler<OrderPlaced>> yields [AuditHandler<OrderPlaced>, ProjectionHandler<OrderPlaced>] - the canonical MediatR-style N-handlers-per-message model. This is the collections x open generics intersection; neither feature handled it alone, because open-generic expansion stopped at the single-dispatch winner and collections built only over already-registered closed members. ExpandOpenGenerics now expands every open registration whose open form matches a required closed service, not just the first. A single match yields one closed registration (the unchanged single-dispatch case); several matches yield one closed implementation per open registration, in declaration order, deduped by closed implementation. Expansion is no longer blocked when a concrete closed registration already exists, so an explicitly-registered closed member coexists with the open-expanded ones as additional collection members. Because expansion runs before coalescing, the synthesized closed registrations flow through the existing coalescing as ordinary unkeyed registrations - the first wins single dispatch, all of them join the collection in registration order - so the existing collection array-literal emission produces the closed array with no Emitter changes. Seeding is unchanged in shape but widened: RequiredServiceTypes already unwrapped Func<T>, Lazy<T>, IEnumerable<T> and array elements to seed expansion through a consumer's collection parameter; it now also unwraps one Task<…>/ValueTask<…> layer so an awaited single service (Task<IHandler<OrderPlaced>>) seeds expansion through its inner type. The worklist still iterates to a fixpoint, so a synthesized closed implementation that itself depends on a further closed-generic collection is expanded transitively. AWT123 (arity) and AWT124 (constraint) are reused for the expanded closed types; AWT124 is now keyed per closed implementation rather than per closed service, since several implementations can expand at one service. No new diagnostic and no public-API change. AWT101 does not fire for an empty open-generic collection (an element type with no open or closed registration resolves to an empty array), matching the concrete-collection rule. Adds runtime behavior tests across all target frameworks and a source-generator snapshot of the emitted closed-collection array literal with both expanded resolvers.
…lection and reject unsupported open registrations Open generic expansion decided which closed generics to synthesize by scanning each implementation's greediest public constructor, a heuristic that diverged from the SelectConstructor the emitter uses to build the same implementation. RequiredServiceTypes now calls SelectConstructor, so the seed scans exactly the constructor the container resolves: it honors internal/protected-internal constructors in the container's assembly (a service whose only accessible constructor is internal now seeds expansion instead of surfacing a false AWT101) and follows the resolvable-then-greediest preference rather than always the greediest. Closing the multi-constructor edge, SelectConstructor gains an optional additionallySatisfiable predicate. A closed generic dependency is not in the registered set during seeding - it is registered only because seeding scans the constructor that needs it - so a constructor resolvable only through an expandable open generic looked unresolvable and the seed fell back to the greedy constructor, never expanding the dependency. The predicate lets a parameter whose closed generic is expandable from an open registration count as satisfiable, so the seed selects the same constructor the emitted container resolves. The per-parameter unwrap is extracted into RequiredServiceType and shared by both the seed scan and the predicate, so a constructor's resolvability and the services scanned from it cannot drift. The emitter passes no predicate: by emit time the closed generics are registered, so its selection is unchanged. Two unsupported open registrations that previously produced silently wrong output are now rejected. AWT127: the typeof-argument form is for open generics and must receive an unbound generic - a closed generic (typeof(Repository<int>)) would be silently reduced to its open definition, dropping the type arguments, and a non-generic type matches no closed service, so both are rejected in favor of the generic attribute form. AWT128: expansion maps a closed service's type arguments onto the implementation's type parameters positionally, which is only correct when the implementation exposes the service with its type parameters in declaration order; a reordered or remapped implementation (Repository<TKey, TValue> : IRepository<TValue, TKey>) would construct a closed type that does not satisfy the requested service, so it is rejected instead of emitting a broken registration. Constraint checking no longer reports a false AWT126 for a constructed constraint that mentions the type parameter. A constraint such as where T : IComparable<T> was compared unsubstituted against the argument's interfaces (IComparable<Order> never equals IComparable<T>) and wrongly rejected a valid argument; such constraints, like a bare where T : U, are now skipped rather than treated as violations, since checking them faithfully needs type-parameter substitution the v1 check does not perform. Adds source-generator tests for the internal-constructor seed, the multi-constructor resolvable-constructor selection, AWT127 (closed and non-generic typeof arguments), AWT128 (reordered type parameters), and the self-referential-constraint AWT126 non-report.
🚀 Benchmark ResultsDetails
Details
Benchmarks with issues: Details
|
👽 Mutation ResultsAwaitenDetails
The final mutation score is 0.00%Coverage Thresholds: high:80 low:60 break:0 |
…s, and support keyed open registrations Bound open generic expansion so a self-growing registration (an implementation whose constructor depends on an ever-larger closed generic of the same open registration, e.g. Node<T> depending on Node<List<T>>) no longer expands without limit and hangs the compiler. Expansion now carries a depth per worklist item and stops at a depth limit; a total-count ceiling additionally bounds a branching recursion that would explode across breadth before reaching that depth. Either case reports the new AWT129 and terminates rather than looping until it exhausts memory. Check open generic implementation constraints that mention a type parameter (where T : IComparable<T>, where T : U) by substituting the closed type arguments into the constraint before checking assignability, instead of skipping them. A constraint the implementation declares but the service does not - so the closed service is legal at the use site yet the implementation is not - is now reported as AWT126 rather than silently synthesizing a registration that fails to compile with CS0311. A constraint that cannot be reconstructed from symbols alone (an array/pointer type argument, or an unmapped type parameter) is still skipped. Support a resolution Key on the open generic ([Transient(typeof(Repository<>), typeof(IRepository<>), Key = "k")]) lifetime attributes, matching the generic attribute forms. The key flows onto every closed implementation expanded from the registration, so a consumer selects one with [FromKey] exactly as for a hand-written closed registration; expansion dedups synthesized implementations by (implementation, key) so the same implementation registered under two keys survives as two registrations. Refactor ContainerRegistrations to bring the expansion worklist, constructor-parameter unwrapping, and constraint checking under the allowed cognitive-complexity limit, and simplify the membership loops with LINQ.
…predicate Fold the seen-implementation dedup into a Where clause on the seed loop over the known registrations, matching the LINQ shape used elsewhere in the expansion.
…n a constraint violation When a required closed generic cannot be synthesized because its type arguments violate the open implementation's constraints (AWT126), the closed service is deliberately left unregistered. The consumer's parameter then also tripped AWT101 (missing registration), reporting one root cause twice and misleadingly implying a forgotten registration. Collect now returns the set of closed services rejected on a constraint violation, and parameter classification suppresses AWT101 for a parameter whose service type is in that set - the AWT126 constraint violation is the single reported cause. A collection element was already exempt from AWT101 (an unregistered element yields an empty collection), so this only affects direct and relationship dependencies.
…ntext Fold the container symbol, compilation, coalesced resolution maps, well-known types, constraint-rejected services, and diagnostics sink threaded to each per-implementation build into a single BuildContext record, so BuildInstance takes the implementation and one context rather than eight parameters. The callees keep their own signatures; the context is unpacked at the top of BuildInstance.
|
…#41) by Valentin Breuß
…#41) by Valentin Breuß



Adds open generic support to the Awaiten compile-time DI container: register an unbound implementation once (
[Transient(typeof(Repository<>), typeof(IRepository<>))]) and let the container construct the matching closed implementation for every closed service the object graph requires.What this adds
Type-argument form of the[Singleton]/[Transient]/[Scoped]attributes, because an unbound generic (typeof(Repository<>)) cannot be a generic type argument:[Transient(typeof(Repository<>))]— registers the open implementation as itself.[Transient(typeof(Repository<>), typeof(IRepository<>))]— registers it under an open service.[Transient(typeof(Repository<>), typeof(IRepository<>), Key = "primary")]— keyed, selected with[FromKey].IRepository<Order>) is required, the matching closed implementation (Repository<Order>) is synthesized and dispatched under the closed service, respecting the registration's lifetime (a singleton caches one instance per closed type argument; a transient is fresh each time).Handler<Order>→IValidator<Order>→Validator<Order>).IEnumerable<IHandler<OrderPlaced>>,IHandler<OrderShipped>[],IReadOnlyList<IHandler<T>>) resolves to every open registration closed at that argument, in declaration order, each respecting its own lifetime. Explicit closed registrations coexist with open-expanded members in the same collection.Func<T>,Lazy<T>,Task<T>/ValueTask<T>, or an awaited collection (Task<IReadOnlyList<IHandler<T>>>) still drives expansion.How it works
Expansion is compile-time and demand-driven: it is seeded from the constructor parameters of the registrations already known to the container and grows as closed implementations are synthesized. Crucially, the seed selects each constructor with the same
SelectConstructorthe emitter uses to build the type (extended with an "expandable via an open registration" predicate so a not-yet-expanded closed generic doesn't disqualify the constructor). This keeps the set of scanned parameters identical to the set the emitted container actually resolves, rather than a divergent heuristic.A closed service's type arguments are mapped positionally onto the implementation's type parameters and the closed type is constructed via Roslyn symbol construction. Synthesized registrations are ordinary unkeyed (or keyed) registrations from coalescing onward, so single dispatch (first wins) and collection membership behave exactly like hand-written closed registrations.
Diagnostics
where T : IComparable<T>, checked by substituting the closed arguments).typeof-argument form received a closed generic or a non-generic type instead of an unbound open generic; points at the generic attribute form instead.Repository<TKey, TValue> : IRepository<TValue, TKey>), so positional mapping would produce the wrong closed type.Robustness
Node<T>depending onNode<List<T>>) would otherwise synthesize an ever-larger closed type forever and hang the compiler. Expansion now carries a per-item depth and stops at a depth limit; a total-count ceiling additionally bounds a branching recursion that would explode across breadth before reaching that depth. Both report AWT129 and terminate.Known limitations
Resolve<IRepository<SomeType>>()works only whenIRepository<SomeType>appears somewhere in the graph. This is inherent to compile-time expansion.