Skip to content

feat: wrap registered services in decorator chains via [Decorate<TDecorator, TService>]#40

Merged
vbreuss merged 6 commits into
mainfrom
feat/decorators
Jul 1, 2026
Merged

feat: wrap registered services in decorator chains via [Decorate<TDecorator, TService>]#40
vbreuss merged 6 commits into
mainfrom
feat/decorators

Conversation

@vbreuss

@vbreuss vbreuss commented Jul 1, 2026

Copy link
Copy Markdown
Member

A service can now be decorated with [Decorate<LoggingDecorator, IService>], so every consumer of IService receives LoggingDecorator(inner) instead of the bare implementation: the decorator's single constructor parameter assignable to the service type is handed the decorated registration, its other parameters resolve from the graph as usual, and the decorator inherits the decorated registration's lifetime. Multiple decorators of one service chain in declaration order — last declared is outermost — so [Decorate<IService, D1>] then [Decorate<IService, D2>] yields D2(D1(Real)); an optional Order property positions a decorator explicitly rather than by declaration order.

Decorators are pure model-building over the existing keyed-resolution plumbing — no new resolution emission. A new BuildDecoratorChains step runs after coalescing: for each decorated service it locates the base implementation(s), moves each base impl onto a synthetic key, registers every decorator as an instance whose inner parameter is redirected to the next-lower link's synthetic key, and rewrites the public unkeyed winner to the outermost decorator. The existing keyed resolver and Names emission then produce new D2(new D1(new Real())) for free, and the inner edge is an ordinary direct dependency so AWT102 cycle and AWT105 captive analysis work unchanged.

Decorators are unbypassable through collections. Because serviceMembers is populated during coalescing from the unkeyed base registration while BuildDecoratorChains runs afterwards and moves the base impl onto a synthetic key, the step rewrites collection membership: a single base impl is replaced by the chain's outermost decorator, so a collection view yields exactly one element — the full chain — never the bare implementation; a service with several registrations is decorated member by member, so IEnumerable<IService> over [Real1, Real2] decorated by D resolves to [D(Real1), D(Real2)] while the public single-dispatch winner is the decorated first-wins impl.

Wrapping several base implementations needs distinct instances of one decorator type, which the single-instance-per-type model could not express, so InstanceModel gains an EmitType: a decorator chain link carries a synthetic ImplementationType identity (so it is keyed, cached and collection-tracked as a distinct instance) while EmitType holds the real decorator type to construct. The emitter constructs and types instances through the new ConstructedType (EmitType ?? ImplementationType); every other use of ImplementationType remains the instance identity, so ordinary registrations are unaffected.

Two diagnostics guard the form. AWT123 (error) reports a [Decorate] that names a service with no registration to decorate. AWT124 (error) reports a decorator whose constructor has no — or more than one ambiguous — parameter assignable to the decorated service type, so Awaiten cannot tell which parameter receives the inner instance.

AWK->AWT mapping: the prototype's AWK201/AWK202 land here as AWT123/AWT124 (the next free ids on main). Adds runtime behavior tests across all target frameworks, source-generator tests for the emitted chained dispatch, AWT123 and AWT124, and updates the public-API approval baselines for the new DecorateAttribute<,>.

…vice, TDecorator>]

A service can now be decorated with [Decorate<IService, LoggingDecorator>], so every consumer of IService receives LoggingDecorator(inner) instead of the bare implementation: the decorator's single constructor parameter assignable to the service type is handed the decorated registration, its other parameters resolve from the graph as usual, and the decorator inherits the decorated registration's lifetime. Multiple decorators of one service chain in declaration order — last declared is outermost — so [Decorate<IService, D1>] then [Decorate<IService, D2>] yields D2(D1(Real)); an optional Order property positions a decorator explicitly rather than by declaration order.

Decorators are pure model-building over the existing keyed-resolution plumbing — no new resolution emission. A new BuildDecoratorChains step runs after coalescing: for each decorated service it locates the base implementation(s), moves each base impl onto a synthetic key (__dec:S:k:0), registers every decorator as an instance whose inner parameter is redirected to the next-lower link's synthetic key, and rewrites the public unkeyed winner to the outermost decorator. The existing keyed resolver and Names emission then produce new D2(new D1(new Real())) for free, and the inner edge is an ordinary direct dependency so AWT102 cycle and AWT105 captive analysis work unchanged.

Decorators are unbypassable through collections. Because serviceMembers is populated during coalescing from the unkeyed base registration while BuildDecoratorChains runs afterwards and moves the base impl onto a synthetic key, the step rewrites collection membership: a single base impl is replaced by the chain's outermost decorator, so a collection view yields exactly one element — the full chain — never the bare implementation; a service with several registrations is decorated member by member, so IEnumerable<IService> over [Real1, Real2] decorated by D resolves to [D(Real1), D(Real2)] while the public single-dispatch winner is the decorated first-wins impl.

Wrapping several base implementations needs distinct instances of one decorator type, which the single-instance-per-type model could not express, so InstanceModel gains an EmitType: a decorator chain link carries a synthetic ImplementationType identity (so it is keyed, cached and collection-tracked as a distinct instance) while EmitType holds the real decorator type to construct. The emitter constructs and types instances through the new ConstructedType (EmitType ?? ImplementationType); every other use of ImplementationType remains the instance identity, so ordinary registrations are unaffected.

Two diagnostics guard the form. AWT123 (error) reports a [Decorate] that names a service with no registration to decorate. AWT124 (error) reports a decorator whose constructor has no — or more than one ambiguous — parameter assignable to the decorated service type, so Awaiten cannot tell which parameter receives the inner instance.

AWK->AWT mapping: the prototype's AWK201/AWK202 land here as AWT123/AWT124 (the next free ids on main). Adds runtime behavior tests across all target frameworks, source-generator tests for the emitted chained dispatch, AWT123 and AWT124, and updates the public-API approval baselines for the new DecorateAttribute<,>.
@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 18s ⏱️ -7s
  326 tests + 25    325 ✅ + 25  1 💤 ±0  0 ❌ ±0 
1 615 runs  +131  1 614 ✅ +131  1 💤 ±0  0 ❌ ±0 

Results for commit 0dacfca. ± Comparison against base commit a79f892.

♻️ This comment has been updated with latest results.

@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

@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 9V74 3.69GHz, 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

Resolve Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 15.534 ns 0.0455 ns 0.0380 ns 1.40 - NA
Awaiten 8 11.088 ns 0.0101 ns 0.0084 ns 1.00 - NA
MsDI 8 6.438 ns 0.0144 ns 0.0127 ns 0.58 - NA
Autofac 8 88.746 ns 0.2384 ns 0.2114 ns 8.00 656 B NA
Jab 8 2.442 ns 0.0874 ns 0.0730 ns 0.22 - NA
PureDI 8 4.514 ns 0.0208 ns 0.0185 ns 0.41 - NA
DryIoc 8 7.125 ns 0.0546 ns 0.0456 ns 0.64 - NA
SimpleInjector 8 8.600 ns 0.1439 ns 0.1346 ns 0.78 - NA
baseline* 256 1,224.420 ns 1.5207 ns 1.3481 ns 1.08 - NA
Awaiten 256 1,130.238 ns 0.3601 ns 0.3368 ns 1.000 - NA
MsDI 256 6.444 ns 0.0084 ns 0.0074 ns 0.006 - NA
Autofac 256 88.641 ns 0.7470 ns 0.5832 ns 0.078 656 B NA
Jab 256 36.165 ns 0.0458 ns 0.0382 ns 0.032 - NA
PureDI 256 6.088 ns 0.0059 ns 0.0049 ns 0.005 - NA
DryIoc 256 7.335 ns 0.0027 ns 0.0024 ns 0.006 - NA
SimpleInjector 256 8.739 ns 0.0126 ns 0.0106 ns 0.008 - 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* 258.5 ns 0.89 ns 0.78 ns 0.62 560 B 1.00
Awaiten 417.6 ns 100.37 ns 93.88 ns 1.04 560 B 1.00
MsDI 1,413.8 ns 342.06 ns 319.96 ns 3.53 1104 B 1.97
Autofac 14,389.4 ns 2,277.83 ns 2,130.69 ns 35.92 13696 B 24.46
Jab 375.1 ns 56.27 ns 52.64 ns 0.94 432 B 0.77
DryIoc 811.2 ns 93.44 ns 87.40 ns 2.02 944 B 1.69
SimpleInjector 1,505.9 ns 299.55 ns 280.20 ns 3.76 1096 B 1.96
PureDI 366.8 ns 77.10 ns 72.12 ns 0.92 632 B 1.13
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 18.42 ns 0.332 ns 0.294 ns 0.94 136 B 1.00
Awaiten 8 19.60 ns 0.895 ns 0.837 ns 1.00 136 B 1.00
MsDI 8 1,686.17 ns 22.107 ns 20.679 ns 86.17 5688 B 41.82
Autofac 8 29,655.75 ns 114.780 ns 107.365 ns 1,515.54 33098 B 243.37
Jab 8 13.66 ns 0.519 ns 0.485 ns 0.70 96 B 0.71
PureDI 8 20.27 ns 0.297 ns 0.278 ns 1.04 128 B 0.94
DryIoc 8 802.92 ns 3.613 ns 3.203 ns 41.03 1472 B 10.82
SimpleInjector 8 13,034.37 ns 125.194 ns 117.107 ns 666.11 24761 B 182.07
baseline* 256 87.45 ns 2.353 ns 2.086 ns 0.64 2120 B 1.00
Awaiten 256 137.62 ns 6.765 ns 6.328 ns 1.00 2120 B 1.00
MsDI 256 16,955.37 ns 150.298 ns 140.589 ns 123.45 61016 B 28.78
Autofac 256 718,150.35 ns 5,323.390 ns 4,719.046 ns 5,228.67 738238 B 348.23
Jab 256 111.92 ns 8.541 ns 7.989 ns 0.81 2080 B 0.98
PureDI 256 130.35 ns 15.463 ns 14.464 ns 0.95 2112 B 1.00
DryIoc 256 47,222.48 ns 437.397 ns 409.141 ns 343.81 80114 B 37.79
SimpleInjector 256 382,815.21 ns 8,191.277 ns 7,662.126 ns 2,787.18 573055 B 270.31

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

vbreuss added 4 commits July 1, 2026 16:58
Resolve a base-typed inner parameter through the decorated service key rather than the parameter's own declared type: chain links are registered only under the decorated service type, so a decorator whose inner parameter is typed as a base of the service (e.g. Dec(IBase inner) decorating IService) previously looked up a (baseType, syntheticKey) pair that was never registered and crashed the generator with a KeyNotFoundException. DecoratorInner now carries the decorated service type and the redirect rewrites both the parameter's service type and key.

Validate the decorator against the same constructor the container actually builds by delegating to SelectConstructor, instead of an independent public-only greediest-by-arity choice. A divergent choice could leave the inner parameter un-redirected and the link resolving itself into a cycle; the shared routine also accepts internal same-assembly constructors.

Disambiguate the inner parameter as the most-derived assignable constructor parameter rather than rejecting any decorator with more than one parameter implicitly convertible from the service. A plain object (or shared base) sibling parameter no longer falsely reports AWT124; only a genuine tie between equally-derived assignable parameters is ambiguous.

Document that the decorator and the implementation it wraps are each owned by the container and disposed independently, outermost first, so a decorator that also disposes its inner double-disposes it.

Add runtime tests for scoped-lifetime, async-initializable, base-typed-inner and disposal-order decorators, and generator tests for the object-sibling and internal-constructor cases.
…-parameter order

Extract the decorator-chain construction out of the single deeply nested BuildDecoratorChains method into a DecoratorChainBuilder that holds the mutable graph state as fields, so each step reads as a small focused method (GroupByService, ProcessService, ResolveInnerParameterTypes, WrapBaseImpl, AddChainLink, MoveBaseToSyntheticKey, RewriteCollectionMembership). This drops the method's cognitive complexity below the threshold and its parameter count from eight to seven. Extract the decorator inner-parameter redirect from ClassifyParameters into RedirectDecoratorInner to bring that method back under the complexity threshold too.

Flip the type-parameter order of [Decorate] from <TService, TDecorator> to <TDecorator, TService> so the concrete type comes first, matching the lifetime attributes ([Transient<TImplementation, TService>] and friends). Consistency across the registration surface outweighs matching the service-first convention some other libraries use, and the feature is unreleased so the change costs nothing downstream. Update the generator's positional read of the attribute type arguments, all decorator registrations in the tests, and the public API baselines accordingly.

Suppress S2326 on DecorateAttribute (its type parameters are the generator's input, read via Roslyn symbols) following the same pattern as the lifetime attributes, and give the test Clock an instance field so its Now() stays a genuine instance member rather than a static utility.
…aking into diagnostics

Move SingleInnerParameterType, DecoratorKey and DecoratorIdentity - all used only by DecoratorChainBuilder - inside that class, so the chain-building surface is self-contained rather than spread across free-standing statics on the generator. SingleInnerParameterType becomes an instance method reading the builder's existing _containerSymbol/_serviceToImpl/_compilation fields instead of re-passing them. DecoratorKeyPrefix stays on the enclosing type because the shared DisplayInstance helper needs it too and an enclosing type cannot see a nested type's private member.

Render decorator chain links by their real type in diagnostics. A chain link carries a synthetic '<type>@__dec:...' identity, which previously leaked verbatim into AWT101 and AWT102 messages (e.g. 'MyCode.Deco@__dec:MyCode.IService:0:1'); a new DisplayInstance helper trims the synthetic suffix so an error names 'MyCode.Deco'.

Exclude a [FromKey] parameter from inner-parameter selection. A keyed parameter deliberately selects a specific keyed registration, so it is a separate dependency, never the chain inner (which is redirected by key and would ignore the [FromKey] anyway). A decorator whose only service-assignable parameter is [FromKey]-ed now reports a clear AWT124 rather than resolving the inner to an unregistered keyed service and surfacing a confusing AWT101 against the synthetic identity, and a decorator may now legitimately take a keyed service as a side dependency alongside its unkeyed inner.

Document that only the unkeyed registration of a service is decorated. Add generator tests for the keyed-inner AWT124 and for diagnostics naming the real decorator type rather than the synthetic identity.
…tic, not just AWT101/AWT102

The prior change routed AWT101 and AWT102 through DisplayInstance but missed the other diagnostics that can name a decorator chain link: a link inherits its base's lifetime (so a singleton decorator with a scoped side-dependency reports AWT105 captive dependency), and a link is a collection member and can be async-tainted (so it can surface in the AWT120/AWT122 async-path diagnostics). Route every instance-identity render that can name a link through DisplayInstance so the synthetic '@__dec:' identity never leaks. The two remaining Display(info.ImplementationType) sites (AWT100 no-accessible-constructor, AWT119 parameterized-lifetime) are left untouched because a chain link cannot reach them - its constructor is validated before the chain is built and it is never a parameterized [Arg] service. DisplayInstance is a no-op on ordinary identities, so non-decorator diagnostics are unaffected.

Add a generator test that a singleton decorator captively holding a scoped side-dependency reports AWT105 naming the real decorator type with no synthetic identity leaked.
@vbreuss vbreuss changed the title feat: wrap registered services in decorator chains via [Decorate<TService, TDecorator>] feat: wrap registered services in decorator chains via [Decorate<TDecorator, TService>] Jul 1, 2026
…coratorInner

When RedirectDecoratorInner was extracted out of ClassifyParameters, it was inserted directly beneath the ClassifyParameters XML-doc summary, leaving that method with two stacked <summary> blocks - the first describing parameter classification and AWT101, the second describing the inner-parameter redirect - and leaving ClassifyParameters itself undocumented. Move the classification summary back down onto ClassifyParameters so each method carries only its own summary. Documentation only; no behavior change.
@vbreuss vbreuss enabled auto-merge (squash) July 1, 2026 18:02
@sonarqubecloud

sonarqubecloud Bot commented Jul 1, 2026

Copy link
Copy Markdown

@vbreuss vbreuss merged commit effe9d7 into main Jul 1, 2026
14 checks passed
@vbreuss vbreuss deleted the feat/decorators branch July 1, 2026 18:07
github-actions Bot added a commit that referenced this pull request Jul 1, 2026
…ator chains via `[Decorate<TDecorator, TService>]` (#40) by Valentin Breuß
github-actions Bot added a commit that referenced this pull request Jul 1, 2026
…ator chains via `[Decorate<TDecorator, TService>]` (#40) 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