Skip to content

feat: support open generic registration#41

Merged
vbreuss merged 7 commits into
mainfrom
feat/open-generics
Jul 2, 2026
Merged

feat: support open generic registration#41
vbreuss merged 7 commits into
mainfrom
feat/open-generics

Conversation

@vbreuss

@vbreuss vbreuss commented Jul 1, 2026

Copy link
Copy Markdown
Member

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

  • Open generic registration via a new non-generic 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].
  • Closed construction on demand. When a closed service (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).
  • Transitive expansion. A synthesized closed implementation's own constructor may depend on further closed generics; the expansion iterates to a fixpoint (Handler<Order>IValidator<Order>Validator<Order>).
  • Open generic collections. A collection of a closed generic (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.
  • Relationship / awaited shapes seed expansion too. A closed generic reached through 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 SelectConstructor the 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

ID When
AWT125 The open implementation and service have different arity, so no closed service can be mapped onto the implementation.
AWT126 A required closed type argument violates the implementation's type-parameter constraints (including self-referential constraints such as where T : IComparable<T>, checked by substituting the closed arguments).
AWT127 The 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.
AWT128 The implementation does not expose its service with its type parameters in declaration order (e.g. Repository<TKey, TValue> : IRepository<TValue, TKey>), so positional mapping would produce the wrong closed type.
AWT129 Expansion nested beyond the supported limit, indicating an unbounded generic recursion; expansion is bounded and reported rather than looping until it exhausts memory.

Robustness

  • Bounded expansion. A self-growing registration (Node<T> depending on Node<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.
  • Faithful constraint checking. Constraints that mention a type parameter are checked after substituting the closed arguments, so an implementation-only constraint the service does not share is reported as AWT126 instead of silently emitting code that fails to compile with CS0311. Constraints that cannot be reconstructed from symbols alone (array/pointer type arguments) are skipped safely.

Known limitations

  • Demand-driven only. A closed generic is expanded only if some registered implementation's constructor references it (directly or through a collection/relationship). There is no runtime fallback, so Resolve<IRepository<SomeType>>() works only when IRepository<SomeType> appears somewhere in the graph. This is inherent to compile-time expansion.
  • Exact arity, in-order mapping. The implementation's type parameters must map one-to-one, in declaration order, onto the service's (enforced by AWT125/AWT128).

vbreuss added 3 commits July 1, 2026 20:55
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.
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Test Results

   18 files  ±  0     18 suites  ±0   1m 40s ⏱️ +18s
  358 tests + 32    357 ✅ + 32  1 💤 ±0  0 ❌ ±0 
1 767 runs  +152  1 766 ✅ +152  1 💤 ±0  0 ❌ ±0 

Results for commit 6f133fd. ± Comparison against base commit effe9d7.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 3.19GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Resolve Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 13.344 ns 0.0093 ns 0.0073 ns 1.08 - NA
Awaiten 8 12.3634 ns 0.0129 ns 0.0101 ns 1.00 - NA
MsDI 8 8.9269 ns 0.2552 ns 0.2131 ns 0.72 - NA
Autofac 8 110.1593 ns 2.3333 ns 2.1826 ns 8.91 656 B NA
Jab 8 0.6065 ns 0.0088 ns 0.0073 ns 0.05 - NA
PureDI 8 5.4592 ns 0.0045 ns 0.0037 ns 0.44 - NA
DryIoc 8 9.0440 ns 0.0354 ns 0.0314 ns 0.73 - NA
SimpleInjector 8 11.2377 ns 0.0206 ns 0.0183 ns 0.91 - NA
baseline* 256 1,296.610 ns 1.8066 ns 1.6015 ns 1.00 - NA
Awaiten 256 1,292.7732 ns 0.3599 ns 0.3005 ns 1.000 - NA
MsDI 256 7.7542 ns 0.3645 ns 0.3231 ns 0.006 - NA
Autofac 256 113.0392 ns 1.2902 ns 1.1437 ns 0.087 656 B NA
Jab 256 0.9536 ns 0.0017 ns 0.0013 ns 0.001 - NA
PureDI 256 7.5989 ns 0.0031 ns 0.0024 ns 0.006 - NA
DryIoc 256 9.0583 ns 0.0049 ns 0.0038 ns 0.007 - NA
SimpleInjector 256 15.2031 ns 0.0614 ns 0.0574 ns 0.012 - NA
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Realistic Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 225.9 ns 1.83 ns 1.52 ns 0.95 560 B 1.00
Awaiten 239.0 ns 1.48 ns 1.24 ns 1.00 560 B 1.00
MsDI 604.8 ns 4.42 ns 3.69 ns 2.53 1104 B 1.97
Autofac 7,796.8 ns 73.07 ns 68.35 ns 32.63 13696 B 24.46
Jab NA NA NA ? NA ?
DryIoc 395.2 ns 5.57 ns 5.21 ns 1.65 944 B 1.69
SimpleInjector 707.0 ns 2.54 ns 2.25 ns 2.96 1096 B 1.96
PureDI 169.9 ns 1.93 ns 1.61 ns 0.71 632 B 1.13

Benchmarks with issues:
RealisticResolveBenchmarks.Realistic_Jab: InProcess(Toolchain=InProcessEmitToolchain, IterationCount=15, LaunchCount=1, WarmupCount=10)

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.301
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Build Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 16.536 ns 0.3470 ns 0.3245 ns 1.04 136 B 1.00
Awaiten 8 15.894 ns 0.0793 ns 0.0703 ns 1.00 136 B 1.00
MsDI 8 1,557.488 ns 34.2641 ns 32.0507 ns 98.00 5688 B 41.82
Autofac 8 30,570.483 ns 417.8710 ns 390.8768 ns 1,923.47 33098 B 243.37
Jab 8 9.578 ns 0.7125 ns 0.6317 ns 0.60 32 B 0.24
PureDI 8 16.734 ns 1.0325 ns 0.9658 ns 1.05 128 B 0.94
DryIoc 8 767.828 ns 6.2086 ns 5.8076 ns 48.31 1472 B 10.82
SimpleInjector 8 13,361.845 ns 353.3108 ns 330.4871 ns 840.71 24760 B 182.06
baseline* 256 88.995 ns 2.6313 ns 2.4613 ns 0.86 2120 B 1.00
Awaiten 256 103.922 ns 10.6039 ns 9.9189 ns 1.01 2120 B 1.00
MsDI 256 15,422.172 ns 313.3320 ns 293.0910 ns 149.62 61016 B 28.78
Autofac 256 758,123.159 ns 7,017.8134 ns 6,564.4670 ns 7,355.10 739571 B 348.85
Jab 256 11.294 ns 1.2128 ns 1.1344 ns 0.11 32 B 0.02
PureDI 256 100.789 ns 12.7767 ns 11.9514 ns 0.98 2112 B 1.00
DryIoc 256 47,274.432 ns 1,303.9869 ns 1,219.7501 ns 458.64 80410 B 37.93
SimpleInjector 256 390,901.993 ns 19,485.5096 ns 18,226.7578 ns 3,792.42 573074 B 270.32

baseline* rows show the corresponding Awaiten benchmark from the most recent successful main branch build with results, for regression comparison.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

👽 Mutation Results

Mutation testing badge

Awaiten

Details
File Score Killed Survived Timeout No Coverage Ignored Compile Errors Runtime Errors Total Detected Total Undetected Total Mutants
ScopedAttribute.cs 0.00% 0 2 0 0 0 0 0 0 2 2
SingletonAttribute.cs 0.00% 0 2 0 0 0 0 0 0 2 2
TransientAttribute.cs 0.00% 0 2 0 0 0 0 0 0 2 2

The final mutation score is 0.00%

Coverage Thresholds: high:80 low:60 break:0

vbreuss added 2 commits July 1, 2026 22:07
…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.
@vbreuss vbreuss changed the title feat: support generics feat: support open generic registration Jul 2, 2026
vbreuss added 2 commits July 2, 2026 08:08
…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.
@sonarqubecloud

sonarqubecloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

@vbreuss vbreuss merged commit 8218b8c into main Jul 2, 2026
14 checks passed
@vbreuss vbreuss deleted the feat/open-generics branch July 2, 2026 06:31
github-actions Bot added a commit that referenced this pull request Jul 2, 2026
github-actions Bot added a commit that referenced this pull request Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant