Skip to content

feat: resolve collection dependencies to every registration of a service#39

Merged
vbreuss merged 7 commits into
mainfrom
feat/collections
Jul 1, 2026
Merged

feat: resolve collection dependencies to every registration of a service#39
vbreuss merged 7 commits into
mainfrom
feat/collections

Conversation

@vbreuss

@vbreuss vbreuss commented Jul 1, 2026

Copy link
Copy Markdown
Member

A constructor parameter typed as a collection of a service - IEnumerable<T>, IReadOnlyList<T>, IReadOnlyCollection<T>, IList<T>, ICollection<T> or T[] - now resolves to every unkeyed registration of T, materialized eagerly as an array in registration order, with each member keeping its own lifetime (singletons shared, transients fresh, scoped from the scope). IEnumerable<T> and T[] are also publicly resolvable through Resolve. This is the backbone of handler dispatch, notification fan-out, validation pipelines and plugin models.

The change relaxes the coalescing invariant (one ServiceKey maps to one implementation, first wins, later duplicates dropped) without disturbing single resolution. Duplicate unkeyed registrations now each become an instance; the first still wins by-type resolution, and the losing registrations carry an empty service list so they stay invisible to the single-dispatch, typed-resolver and captive-naming paths, reachable only through the collection via a per-implementation resolver map and the ServiceMembers collections model. Keyed registrations keep their existing handling - collections are an unkeyed concern.

Collections participate in the object graph: an Enumerable parameter contributes an edge to each of its members in both the dependency graph (taint/captive) and the construction graph (cycles), since it captures them eagerly. A singleton holding a collection of scoped services is therefore still an AWT105 captive dependency, and a cycle closed through a collection is still AWT102. An empty collection (no registration for the element type) resolves to an empty array rather than an AWT101 missing-dependency error, and a parameterized ([Arg]) service is excluded from collections since it is reachable only through its Func<TArg…, T> factory.

Collections are synchronous-only in this version: a member that is async-initialized or async-tainted cannot be materialized synchronously, so in the strict default it is reported as the new AWT122 (set SyncResolveAfterInit and resolve after InitializeAsync, or remove the async member). Async-tainted collections are likewise omitted from the public dispatch so no synchronous resolver is referenced where none is emitted. AWK118 in the prototype maps to AWT122 here (AWT118 is already the root-accumulating-factory diagnostic; AWT122 was the next free id).

Adds runtime behavior tests across all target frameworks and source-generator tests for the emitted array literal, the public dispatch, array parameters, empty collections, keyed and parameterized exclusion, captive detection through a collection, and AWT122.

A constructor parameter typed as a collection of a service - `IEnumerable<T>`, `IReadOnlyList<T>`, `IReadOnlyCollection<T>`, `IList<T>`, `ICollection<T>` or `T[]` - now resolves to every unkeyed registration of `T`, materialized eagerly as an array in registration order, with each member keeping its own lifetime (singletons shared, transients fresh, scoped from the scope). `IEnumerable<T>` and `T[]` are also publicly resolvable through `Resolve`. This is the backbone of handler dispatch, notification fan-out, validation pipelines and plugin models.

The change relaxes the coalescing invariant (one `ServiceKey` maps to one implementation, first wins, later duplicates dropped) without disturbing single resolution. Duplicate unkeyed registrations now each become an instance; the first still wins by-type resolution, and the losing registrations carry an empty service list so they stay invisible to the single-dispatch, typed-resolver and captive-naming paths, reachable only through the collection via a per-implementation resolver map and the `ServiceMembers` collections model. Keyed registrations keep their existing handling - collections are an unkeyed concern.

Collections participate in the object graph: an `Enumerable` parameter contributes an edge to each of its members in both the dependency graph (taint/captive) and the construction graph (cycles), since it captures them eagerly. A singleton holding a collection of scoped services is therefore still an `AWT105` captive dependency, and a cycle closed through a collection is still `AWT102`. An empty collection (no registration for the element type) resolves to an empty array rather than an `AWT101` missing-dependency error, and a parameterized (`[Arg]`) service is excluded from collections since it is reachable only through its `Func<TArg…, T>` factory.

Collections are synchronous-only in this version: a member that is async-initialized or async-tainted cannot be materialized synchronously, so in the strict default it is reported as the new `AWT122` (set `SyncResolveAfterInit` and resolve after `InitializeAsync`, or remove the async member). Async-tainted collections are likewise omitted from the public dispatch so no synchronous resolver is referenced where none is emitted. `AWK118` in the prototype maps to `AWT122` here (`AWT118` is already the root-accumulating-factory diagnostic; `AWT122` was the next free id).

Adds runtime behavior tests across all target frameworks and source-generator tests for the emitted array literal, the public dispatch, array parameters, empty collections, keyed and parameterized exclusion, captive detection through a collection, and `AWT122`.
@vbreuss vbreuss self-assigned this Jul 1, 2026
@vbreuss vbreuss added the enhancement New feature or request label Jul 1, 2026
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Test Results

   18 files  ±  0     18 suites  ±0   1m 30s ⏱️ +26s
  301 tests + 33    300 ✅ + 33  1 💤 ±0  0 ❌ ±0 
1 484 runs  +163  1 483 ✅ +163  1 💤 ±0  0 ❌ ±0 

Results for commit e5d53c2. ± Comparison against base commit fcaeb0e.

♻️ 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 2.61GHz, 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 12.411 ns 0.1065 ns 0.0944 ns 0.91 - NA
Awaiten 8 13.669 ns 0.0323 ns 0.0286 ns 1.00 - NA
MsDI 8 7.384 ns 0.0170 ns 0.0142 ns 0.54 - NA
Autofac 8 135.586 ns 1.9629 ns 1.7400 ns 9.92 656 B NA
Jab 8 2.826 ns 0.0041 ns 0.0032 ns 0.21 - NA
PureDI 8 5.429 ns 0.0088 ns 0.0078 ns 0.40 - NA
DryIoc 8 8.766 ns 0.0078 ns 0.0065 ns 0.64 - NA
SimpleInjector 8 10.741 ns 0.0147 ns 0.0138 ns 0.79 - NA
baseline* 256 418.614 ns 0.2114 ns 0.1766 ns 0.32 - NA
Awaiten 256 1,293.856 ns 0.4080 ns 0.3617 ns 1.000 - NA
MsDI 256 7.237 ns 0.0132 ns 0.0110 ns 0.006 - NA
Autofac 256 143.972 ns 5.0255 ns 4.4549 ns 0.111 656 B NA
Jab 256 42.694 ns 0.0367 ns 0.0343 ns 0.033 - NA
PureDI 256 7.639 ns 0.0065 ns 0.0051 ns 0.006 - NA
DryIoc 256 8.720 ns 0.0077 ns 0.0065 ns 0.007 - NA
SimpleInjector 256 14.146 ns 0.0091 ns 0.0076 ns 0.011 - 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* 249.8 ns 6.10 ns 5.70 ns 1.05 560 B 1.00
Awaiten 238.1 ns 5.36 ns 4.75 ns 1.00 560 B 1.00
MsDI 585.0 ns 9.86 ns 8.74 ns 2.46 1104 B 1.97
Autofac 8,051.0 ns 113.62 ns 106.28 ns 33.83 13696 B 24.46
Jab 178.8 ns 2.60 ns 2.43 ns 0.75 432 B 0.77
DryIoc 394.1 ns 6.30 ns 5.89 ns 1.66 944 B 1.69
SimpleInjector 734.7 ns 3.00 ns 2.81 ns 3.09 1096 B 1.96
PureDI 190.8 ns 2.78 ns 2.46 ns 0.80 632 B 1.13
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V45 2.60GHz, 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-v4

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

Build Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 18.95 ns 0.962 ns 0.900 ns 4.12 136 B 1.00
Awaiten 8 4.594 ns 0.2369 ns 0.2216 ns 1.00 136 B 1.00
MsDI 8 746.089 ns 7.2469 ns 6.4242 ns 162.77 5688 B 41.82
Autofac 8 16,309.427 ns 145.4599 ns 136.0633 ns 3,558.15 33094 B 243.34
Jab 8 4.425 ns 0.0360 ns 0.0337 ns 0.97 96 B 0.71
PureDI 8 8.268 ns 0.1821 ns 0.1703 ns 1.80 128 B 0.94
DryIoc 8 382.613 ns 2.3096 ns 2.0474 ns 83.47 1528 B 11.24
SimpleInjector 8 6,313.542 ns 45.5105 ns 42.5705 ns 1,377.39 24760 B 182.06
baseline* 256 98.59 ns 7.589 ns 7.099 ns 2.55 2120 B 1.00
Awaiten 256 38.707 ns 0.2094 ns 0.1959 ns 1.00 2120 B 1.00
MsDI 256 7,294.580 ns 31.1046 ns 27.5735 ns 188.46 61016 B 28.78
Autofac 256 390,232.993 ns 2,522.0190 ns 2,359.0982 ns 10,081.86 738820 B 348.50
Jab 256 33.213 ns 0.1363 ns 0.1275 ns 0.86 2080 B 0.98
PureDI 256 36.733 ns 0.2198 ns 0.2056 ns 0.95 2112 B 1.00
DryIoc 256 22,506.018 ns 139.7427 ns 130.7154 ns 581.45 81345 B 38.37
SimpleInjector 256 183,692.508 ns 1,349.3001 ns 1,262.1362 ns 4,745.79 573022 B 270.29

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

The final mutation score is NaN%

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

vbreuss added 6 commits July 1, 2026 08:24
…tainer root

A collection materializes its members eagerly on the resolving scope, but the public `IEnumerable<T>` / `T[]` dispatch honored none of the strict lifetime-safety withholding that governs single resolution. Resolving such a collection by type off the Root repeatedly re-tracked its transient disposable members on the root, accumulating them for the container's lifetime - the exact leak that withholding the singular resolution of such a member (`IsWithheld`) exists to prevent. The collection dispatch is now root-withheld whenever a member is a build-on-demand disposable: it resolves from a child scope (which bounds its members), but by-type resolution on the Root returns false from `TryResolve` and throws targeted guidance from `Resolve`, flowing through the existing `__rootWithheld` mask and `__withheld` table. There is no `Owned<T>` form for a collection, so the new `CollectionWithheldMessage` steers to a child scope, direct injection, or `LifetimeSafety.Loose`.

The transitive-disposable walk `BuildsFreshDisposable` now follows collection edges too: a service whose only fresh-disposable path is through a collection (a consumer of `IEnumerable<T>` over a disposable transient) is detected as building a fresh disposable, so a plain `Func<…>` over it bound to the root is correctly reported as `AWT118` / withheld instead of leaking silently. This mirrors the analysis the branch already extended to the cycle, captive and async-taint graphs - the disposal analysis was the one path that still ignored collection members. A shared `CollectionMemberIndices` helper maps each element service to its member instance indices, threaded through the analyzer and the emitter's `IsFuncWithheld`.

Adds runtime tests (all target frameworks) for a collection of disposable transients being withheld from the Root, resolvable and disposed from a child scope, and root-resolvable under Loose; source-generator tests for the emitted withheld guidance; and an analyzer test for `AWT118` reported through a collection edge. Existing collection diagnostic assertions move off `.Any(...).IsTrue()` to the `Contains("*AWTxxx*").AsWildcard()` form used elsewhere.
…sized collection

A constructor parameter typed as a collection (`IEnumerable<T>` and friends, or `T[]`) was unconditionally synthesized from the unkeyed registrations of its element type, even when the collection type was itself registered as a service. That silently shadowed a legitimate opaque-value registration - a `string[]` of command-line arguments, an `IReadOnlyList<T>` of config - and made injection disagree with the public `Resolve<T>` path, which already lets an explicit registration claim the dispatch slot. `ClassifyParameters` now checks whether the collection type is itself registered: if so, the parameter resolves to that registration as an ordinary direct dependency (matching `Resolve<T>`); only an unregistered collection type falls back to the synthesized collection. This is the least-surprising "if you registered it, you get it" rule and needs no new diagnostic.

Corrects the `AWT122` message and its detection doc, which said the async-tainted-collection error applied "in strict mode". Like `AWT119`/`AWT120` it is a synchronous-resolution-of-async concern independent of lifetime safety - it fires under both strict and loose safety, and only `SyncResolveAfterInit` suppresses it - so the wording now matches those diagnostics.

Refactors the branch's higher-complexity methods without changing behavior: `BuildEdges`, `BuildsFreshDisposable`, `CoalesceByImplementation` and `DetectSynchronousAsyncCollection` in the generator, and `BuildDispatchEntries` in the emitter, each split into focused helpers (`AddParameterEdges`/`AddCollectionMemberEdges`, `PushFreshTransientDependencies`/`PushTransientCollectionMembers`, `ReportCoalescingConflicts`/`AddCollectionMember`, `ReportAsyncTaintedMembers`, `AddCollectionEntries`/`AddCollectionShape`). Adds a runtime test that an explicitly registered `IEnumerable<T>` wins over synthesis for both injection and by-type resolution while the unregistered `T[]` shape still synthesizes, and a source-generator test pinning the emitted direct dependency.
…-nothing synthesis

Recognizes a `[FromKey]` on a collection parameter so `[FromKey("k")] IEnumerable<T>` resolves the registrations of `T` under key `k` instead of silently ignoring the key and returning the unkeyed set. Collection membership is now keyed by `(service type, key)` throughout - coalescing, the dependency/construction graphs, the transitive-disposable walk, AWT122 detection and emission - and `ServiceMembers` carries that key. Public by-type dispatch stays unkeyed-only, matching the container's key model where `Resolve<T>` has no keyed surface. Note that a key identifies at most one registration per service type here (two are AWT117), so a keyed collection holds its single keyed member; the value is that the keyed and unkeyed buckets no longer bleed into each other.

Makes every supported collection shape publicly resolvable, not only `IEnumerable<T>` and `T[]`: `Resolve<IReadOnlyList<T>>()`, `IReadOnlyCollection<T>`, `IList<T>` and `ICollection<T>` now dispatch to the same eagerly materialized array (which satisfies each). Injection already accepted all six shapes, so this makes the public surface symmetric with it.

Adopts all-or-nothing synthesis for an element type whose collection is explicitly registered. Previously a registered `IEnumerable<T>` won its own shape while the other shapes still synthesized from the members, so `Resolve<IEnumerable<T>>()` and `Resolve<T[]>()` could silently return different contents. Now registering any collection shape of `T` suppresses synthesis for every shape of `T`: the registered shape resolves to its opaque value, and an unregistered sibling shape is a plain missing dependency (AWT101 on injection, a throw on by-type resolution) rather than a divergent second collection. `CollectionShapeTypes` is shared by the generator (injection classification) and the emitter (public dispatch) so the two never drift.

Surfaces AWT122-style guidance when a collection with an async-tainted member is requested by type but never injected. Such a collection has no synchronous materialization, so instead of a generic "no registration" its shapes now carry guidance in the `__withheld` table (set `SyncResolveAfterInit` and resolve after `InitializeAsync`, or remove the async member), mirroring how a single async-only service is handled.

Fixes a multidimensional array parameter (`T[,]`) being misclassified as a collection: `TryGetCollectionElement` only matched a rank-1 array before, but any-rank arrays fell through, so an unregistered `int[,]` emitted a rank-1 `new int[] { }` literal that failed to compile (CS1503). It is now recognized as an ordinary direct dependency, so an unregistered one surfaces as AWT101.

Adds runtime and source-generator tests for each: all six shapes resolvable, keyed-collection routing and bucket disjointness, the multidimensional-array AWT101, the async-collection guidance, and the all-or-nothing suppression on both injection and by-type resolution.
…r warm-up

Add a runtime test that resolves a collection holding an IAsyncInitializable member from a SyncResolveAfterInit container after InitializeAsync, asserting the member comes back fully initialized both when injected through a host and when resolved publicly by type. This exercises the emitted synchronous collection literal calling the sync-delegating resolver of an async-tainted member after warm-up - the path previously covered only by a diagnostic-absence test (AWT122 is suppressed under SyncResolveAfterInit).

Also add the missing trailing newline to DependencyKind.cs to match the surrounding files.
The implInfos dictionary is populated by the EnsureImpl local function across loop iterations, a mutation Sonar's symbolic execution does not follow through the local-function call, so it wrongly reports the TryGetValue as reading an always-empty collection. The looked-up ImplInfo is what ReportCoalescingConflicts compares against to detect the AWT107/AWT111 coalescing conflicts, so the call is required - removing it would silently stop those diagnostics from firing. Suppress the rule locally with a justification, matching the existing S-rule pragma convention in the repo.
Extract the EnsureImpl local function to a private static method that takes implInfos and implOrder as parameters. Its dictionary Add now crosses a real call boundary, so Sonar's symbolic execution no longer misreads the pre-loop implInfos.TryGetValue as reading an always-empty collection - resolving the S4158 false positive without a #pragma suppression. Behavior is unchanged; the pre-loop lookup still returns the implementation's already-recorded ImplInfo for ReportCoalescingConflicts (AWT107/AWT111).
@vbreuss vbreuss force-pushed the feat/collections branch from c367f84 to e5d53c2 Compare July 1, 2026 12:51
@sonarqubecloud

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

@vbreuss vbreuss merged commit a79f892 into main Jul 1, 2026
14 checks passed
@vbreuss vbreuss deleted the feat/collections branch July 1, 2026 12:58
github-actions Bot added a commit that referenced this pull request Jul 1, 2026
…o every registration of a service (#39) by Valentin Breuß
github-actions Bot added a commit that referenced this pull request Jul 1, 2026
…o every registration of a service (#39) by Valentin Breuß
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant