diff --git a/docs/aot-di-container-research.md b/docs/aot-di-container-research.md new file mode 100644 index 000000000..2e6410369 --- /dev/null +++ b/docs/aot-di-container-research.md @@ -0,0 +1,239 @@ +# Research: AoT-ready DI container to replace SimpleInjector + +Status: **clarifications received — recommendation issued** (see [§8](#8-clarifications-received-2026-07-02) for the answers and [§7](#7-recommended-solution) for the resulting decision). One question (Q3, migration tolerance) is still open — expanded with examples in §8.3. +Last updated: 2026-07-02. + +--- + +## 1. Context and goal + +Ark.Tools relies heavily on SimpleInjector: decoration (incl. open-generic decoration), startup +verification (`Container.Verify()`) and advanced diagnostics, following a strict "no magic / +no ambiguity" philosophy (same rationale as the NodaTime choice). + +SimpleInjector is **not** NativeAOT/trimming ready and its maintainer has explicitly refused to +support it (see §3). Goal: find a source-generation-based, AoT-ready DI container (or approach) +that preserves the SimpleInjector feature-set Ark.Tools and its samples rely upon. + +--- + +## 2. Research progress (resumable) + +| Step | Status | Where | +|---|---|---| +| Catalog SimpleInjector features used by `src/` and `samples/` | ✅ done | §4 | +| SimpleInjector project AoT status (issues / WIP / forks) | ✅ done | §3 | +| Survey of source-generated AoT-ready DI containers | ✅ done | §5 | +| Feasibility PoC: MEDI + Injectio | ✅ done, passing on CoreCLR + NativeAOT | `evaluations/MediInjectio.Evaluation` | +| Feasibility PoC: Pure.DI | ✅ done, passing on CoreCLR + NativeAOT | `evaluations/PureDi.Evaluation` | +| Trimmed self-contained deployment proof (`PublishTrimmed=true`, `TrimMode=full`) + startup timing | ✅ done, both PoCs pass | §6.6 | +| Clarifications on §8 questions | ✅ received 2026-07-02 (Q3 still open, expanded in §8.3) | §8 | +| Decision on recommended approach | ✅ **Pure.DI primary, MEDI-native fallback** | §7 | +| ASP.NET Core cross-wiring PoC on Pure.DI (`Pure.DI.MS`): controller activation, minimal APIs, health checks | ✅ done — cross-wiring works; **MVC itself cannot run trimmed** (§6.7) | `evaluations/PureDiWeb.Evaluation` | +| Ark source-generator prototype for handler scanning/binding emission (per Q6 + Q3 follow-up: zero manual registration, guaranteed open-generic decoration) | ✅ done — warning-free on trimmed + NativeAOT | `evaluations/HandlerGen.*`, §6.8 | +| Rebus `IHandlerActivator` PoC | ⬜ not started | — | +| Migration plan for `Ark.Tools.Solid.SimpleInjector` / `Ark.Tools.SimpleInjector` / `Ark.Tools.AspNetCore` | ⏸ blocked on Q3 answer | §8.3 | + +Environment notes for whoever resumes: +* PoCs run with SDK 10.0.301 (repo `global.json`); AOT publish verified on `linux-x64` with `PublishAot=true`. +* `evaluations/` is isolated from repo MSBuild inheritance (own `Directory.Build.props/targets/Packages.props`); it is not in `Ark.Tools.slnx` and produces no `packages.lock.json` churn. +* Package versions evaluated: Pure.DI 2.4.3, Injectio 6.1.0, MEDI 10.0.0, Jab 0.12.0 (not PoC'd, disqualified), StrongInject 1.4.4 (not PoC'd, unmaintained). No known vulnerabilities (GitHub advisory DB checked). + +--- + +## 3. SimpleInjector project: AoT status, WIP, forks + +Verified against github.com/simpleinjector/SimpleInjector (mid-2026): + +* **Maintainer explicitly refuses AoT support.** + * [#1013 ".NET Native support not works"](https://github.com/simpleinjector/SimpleInjector/issues/1013) (closed 2024-12-18): *"Simple Injector does not support .NET Native. But it will not support .NET Native in the future… .NET Native applications are better off using Pure DI."* — dotnetjunkie. + * [#264](https://github.com/simpleinjector/SimpleInjector/issues/264) (closed 2019, wontfix): same position. + * [#914 "Compile-time Container Creation"](https://github.com/simpleinjector/SimpleInjector/issues/914) (closed 2021): source generators dismissed — *"compile-time DI systems have no advantage over using Pure DI"*. +* **Trimming**: [#972 "Please support for Trim on self-contained apps"](https://github.com/simpleinjector/SimpleInjector/issues/972) is open since 2023, parked in the perpetual `v6.0` milestone. No PRs about AoT/trimming exist. +* **Failure mode on NativeAOT** is not expression compilation (the runtime falls back to the expression interpreter) but **trimmed reflection metadata**: registered types lose ctor metadata; even `SimpleInjector.VerificationOption[]` loses metadata making `Verify()` throw `NotSupportedException` (#1013). No documented workaround; docs repo has zero AOT/trimming content. +* **Project health**: actively maintained (v5.6.0, 2026-06-28) but effectively single-maintainer (dotnetjunkie). v5.6 turned `EnableDynamicAssemblyCompilation` into a compile error (removing the worst AoT blocker), but the default path is still reflection + `Expression.Compile()`. +* **Forks**: none adding AoT support were found. +* **Forking feasibility**: MIT license, core ≈200–250 C# files / ~30–45 kLOC. The AoT problem is architectural (reflection + runtime `Expression` building throughout `Registration.cs`, `Decorators/*`, `CompilationHelpers.cs`); a fork would need pervasive `DynamicallyAccessedMembers` annotation **plus** a source-generation layer to root/monomorphize closed generics. This is a rewrite of the engine, not a patch — see §5.6. + +--- + +## 4. DI features Ark.Tools and the samples rely upon + +Catalogued from `src/` and `samples/` (notably `samples/Ark.ReferenceProject/Core/Ark.Reference.Core.Application/Host/ApiHost.cs`, `src/aspnetcore/Ark.Tools.AspNetCore/Startup/ArkStartupWebApiCommon.cs`, `src/common/Ark.Tools.SimpleInjector/Ex.cs`): + +| # | Feature | Usage sites (representative) | Criticality | +|---|---|---|---| +| F1 | **Open-generic decorators** — `RegisterDecorator(typeof(IQueryHandler<,>), typeof(PolicyAuthorizeQueryDecorator<,>))`; same for `ICommandHandler<>`, `IRequestHandler<,>`, `IHandleMessages<>` (RebusScope/RebusLog), `IValidator<>` samples | `Ark.Tools.Solid.Authorization/Ex.cs`, `ApiHost.cs`, samples' `ApiHost.cs` | **Critical** — the whole Solid pipeline is built on this | +| F2 | Closed decorators — `RegisterDecorator()`, `IAsyncDocumentSession` auditing | `ApiHost.cs`, `Ark.Tools.RavenDb.Auditing/Ex.cs` | High | +| F3 | **Conditional/fallback registration** — `RegisterConditional(typeof(IValidator<>), typeof(NullValidator<>), c => !c.Handled)`, `PassThroughAuthorizationResourceHandler<,>` | samples' `ApiHost.cs`, `Ark.Tools.Solid.Authorization/Ex.cs` | **Critical** | +| F4 | **Runtime mediator dispatch** — `GetInstance(typeof(IQueryHandler<,>).MakeGenericType(queryType, typeof(TResult)))` + `dynamic` invoke | `Ark.Tools.Solid.SimpleInjector/SimpleInjector{Query,Command,Request}Processor.cs` | **Critical** (note: `dynamic` is itself AoT-hostile, must be replaced regardless of container) | +| F5 | Batch registration / assembly scanning — `GetTypesToRegister(typeof(IValidator<>), assemblies)`, `Collection.Register(typeof(IHandleMessages<>), handlers)` | samples' `ApiHost.cs` | High | +| F6 | Collection resolution — `GetAllInstances>()` (Rebus activator) | `Ark.Tools.Rebus/SimpleInjectorHandlerActivator.cs` | High | +| F7 | **Startup verification + diagnostics** — `Container.Verify()`, `SuppressDiagnosticWarning`, captive-dependency/lifestyle-mismatch analysis | `WorkerHost.cs`, health checks, everywhere implicitly | **Critical** (philosophy: fail at startup, no runtime surprises) | +| F8 | `AsyncScopedLifestyle` + explicit `BeginScope` (Rebus message scope, WorkerHost per-resource scope) | `RebusScopeDecorator.cs`, `WorkerHost.cs` | High | +| F9 | `Func`/`Lazy` auto factories, variant collections/types — `AllowResolvingFuncFactories()`, `AllowToResolveVariantCollections()` (unregistered-type-resolution events) | `Ark.Tools.SimpleInjector/Ex.cs` | Medium | +| F10 | **ASP.NET Core cross-wiring** — `services.AddSimpleInjector(...).AddAspNetCore().AddControllerActivation()`, `app.UseSimpleInjector()`, health-check adapters resolving from the container | `ArkStartupWebApiCommon.cs`, `Ark.Tools.AspNetCore.HealthChecks` | **Critical** for web apps | +| F11 | Low-level registration API — `Lifestyle.CreateRegistration`, `InstanceProducer`, `ContainerLocking` event, metadata (`Get/SetMetadataValue`) | `Ex.cs`, `WorkerHost.cs`, Rebus/Activity extensions | Medium (internal plumbing, replaceable) | +| F12 | Conditional-on-consumer registration (`RegisterConditional` by consumer type), `RegisterInstance`, singleton factories | various | Medium | + +--- + +## 5. Alternatives evaluated + +### 5.1 Pure.DI 2.4.3 (DevTeam/Pure.DI) — MIT, very active (Jun 2026), ~810★ + +* Fluent compile-time DSL in a partial class; generator emits pure constructor calls — zero reflection, **shipped NativeAOT samples**. +* Open generics via **marker types** (`TT`, `TT1`…), monomorphized at compile time. Constraints expressible with **custom markers** (`[GenericTypeArgument] interface TTQuery : IQuery` — proven in our PoC). +* Decorators via tag-chained bindings (`"base"` → decorator). Open-generic decorators expressible with markers — **proven in our PoC**, applied automatically to every handler binding. +* Fallback registration: exact-match binding beats marker binding — **proven** (`IValidator` overrides `IValidator` → `NullValidator`). +* **Full compile-time graph validation** (missing dependency = build error) — the strongest `Verify()` equivalent; closest to the "no magic" philosophy. +* Scoped lifetime via child compositions; `Func`/`Lazy` built in. +* MEDI bridge: `Pure.DI.MS` `ServiceProviderFactory` with two-way resolution (framework services resolved from `IServiceProvider`), AOT-published `MinimalWebAPI` sample exists. +* Gaps: no assembly scanning (explicit bindings only → Ark would generate them, see §7), no runtime late-bound registration, decorator chains need tags on ctor params or factory lambdas. + +### 5.2 MEDI (+ Injectio 6.1.0 registration/decorator generator) — MIT, active + +* MEDI works on NativeAOT (reflection-safe `CallSiteRuntimeResolver`; IL-emit engines disabled) and is the ecosystem default; ASP.NET Core AoT ships on it. +* Injectio generates `IServiceCollection` registrations from attributes (`[RegisterSingleton]`, `[RegisterDecorator]`, keyed services, ordering) — compile-time replacement for scanning (F5). +* Open-generic decoration works **only over closed registrations** (MEDI cannot decorate purely open-generic registrations — factory limitation); attribute-per-handler naturally produces closed registrations, so the Ark pipeline pattern works — **proven in our PoC**. +* **NativeAOT pitfall found by our PoC**: Injectio closes decorator types at runtime via `ActivatorUtilities` → crashes on AOT unless the closed decorator instantiations are rooted. Fix (proven): generated `[DynamicDependency(PublicConstructors, …)]` roots + explicit construction for value-type generic args. An Ark-owned generator must emit these. +* No compile-time graph validation (only runtime `ValidateOnBuild`/`ValidateScopes`), no captive-dependency diagnostics, no `Func`/`Lazy`. Weakest on the "no magic" axis; strongest on ecosystem/integration (F10 becomes a non-issue: one container, no cross-wiring at all). + +### 5.3 StrongInject 1.4.4 (YairHalberstadt/stronginject) — MIT, **unmaintained since 2022**, ~870★ + +* Feature-wise the best SimpleInjector match: real open-generic registration, `[RegisterDecorator]`, **generic `[DecoratorFactory]` methods** (true open-generic decoration), optional-parameter fallback (≈ `!c.Handled`), compile-time errors, `Func`/`Owned`, async resolution. +* Disqualified as a dependency by abandonment (last stable May 2022, pre-dates .NET 7+ AoT tooling, no keyed services). Only viable as a **fork/adoption** — see §8 Q4. + +### 5.4 Jab 0.12.0 (pakrym/jab) — disqualified + +No open generics, no decorators, no conditional registration; dormant since Sep 2025. Fails F1/F3 outright. + +### 5.5 Others + +* **Scrutor**: runtime reflection scanning/decoration — not AoT-viable. +* **MrMeeseeks.DIE** (7★), **ThunderboltIoc** (56★), **SourceInject** (81★): negligible traction/dormant — unacceptable adoption risk. +* **Autofac/Ninject/Lamar**: runtime reflection/IL — no AoT story. +* **No Microsoft source-generated MEDI exists** as of mid-2026. + +### 5.6 Forking SimpleInjector + +* Legally trivial (MIT), technically a **rewrite**: the engine builds `Expression` trees from reflection everywhere; AoT needs both pervasive trimming annotations and a source generator that roots/monomorphizes every closed generic the container may build at runtime (decorators, collections, variance handlers). That generator would have to *see the registrations*, but SimpleInjector registrations are runtime code — a compile-time analyzer cannot reliably recover `Register(typeof(X), assemblies)` results. You would end up designing a new compile-time DSL anyway, i.e. building Pure.DI/StrongInject with the SimpleInjector API surface. Maintenance burden lands entirely on Ark. +* Verdict: **not recommended** unless the clarifications in §8 rule everything else out. + +### 5.7 Comparison vs Ark features (F1–F12) + +| Feature | Pure.DI | MEDI+Injectio(+Ark generator) | StrongInject (fork) | SimpleInjector fork | +|---|---|---|---|---| +| F1 open-generic decorators | ✅ proven (markers+tags) | ✅ proven (closed regs; AoT needs generated roots) | ✅ by design | ✅ but AoT = rewrite | +| F3 conditional fallback | ✅ proven (exact beats marker) | ✅ proven (`TryAdd` open generic) | ✅ optional-param pattern | ✅ | +| F4 runtime dispatch | ✅ proven (`Resolve(Type)` over roots) | ✅ proven (`GetRequiredService(Type)`) | ⚠️ roots must be declared | ✅ | +| F5/F6 scanning + collections | ⚠️ needs Ark generator to emit bindings | ✅ attributes = compile-time scanning | ⚠️ modules only | ✅ | +| F7 verification/diagnostics | ✅✅ compile-time graph | ⚠️ runtime `ValidateOnBuild` only | ✅ compile-time | ✅ | +| F8 scopes | ✅ proven (child composition) | ✅ proven (`IServiceScope`) | ⚠️ `Owned` model differs | ✅ | +| F9 Func/Lazy | ✅ proven | ❌ (needs Ark shim) | ✅ | ✅ | +| F10 ASP.NET Core integration | ✅ proven (`Pure.DI.MS`, §6.7; MVC-trimming limit is container-independent) | ✅✅ native (no cross-wiring at all) | ⚠️ stale sample | ⚠️ port needed | +| AoT proof | ✅ our PoC + upstream samples | ✅ our PoC (with rooting fix) | ❌ unverified | ❌ | +| Maintenance risk | low (active, single-org) | lowest (MS + active Injectio; Injectio replaceable by Ark generator) | **highest** (adopt abandonware) | **highest** (own a container) | +| "No magic" philosophy fit | ✅✅ | ⚠️ runtime graph, best-effort | ✅ | ✅ | + +--- + +## 6. Feasibility proof + +Two assert-based PoCs in [`evaluations/`](../evaluations/README.md), both **passing on CoreCLR and on published NativeAOT binaries** (linux-x64, .NET 10). Each replicates the Ark.Tools.Solid pipeline: two query handlers (`string` and value-type `int` results), a validation decorator resolving `IValidator` with `NullValidator<>` fallback + one specific validator, a second (audit) decorator, scoped lifetime, collection resolution, and mediator dispatch by runtime `Type`. + +Key empirical findings: + +1. **`dynamic` dispatch in `SimpleInjector*Processor` must go regardless of container** — replaced in both PoCs by a default-interface-method bridge (`IQueryHandlerBase`), AoT-safe and allocation-free. +2. `typeof(IQueryHandler<,>).MakeGenericType(q, r)` is AoT-safe **iff** the closed instantiation exists statically (it does — the handler implements it). IL3050 warning remains; a generator emitting a `Dictionary>` root map would silence it. +3. **MEDI+Injectio on AoT fails out-of-the-box** for open-generic decorators (`ActivatorUtilities` cannot find ctor of `ValidationDecorator`): metadata was trimmed. Proven fix (what an Ark generator must emit): `[DynamicDependency(PublicConstructors, typeof(Decorator))]` per closed pair, **plus** an explicit construction for value-type generic arguments (`int` results) since those need real native instantiations, not just metadata. +4. **Pure.DI handles the generic constraint `TQuery : IQuery`** only via a custom marker (`[GenericTypeArgument] interface TTQuery : IQuery`); the stock `TT1/TT2` markers fail to compile against constrained interfaces. Works, but must be codified in guidelines. +5. Pure.DI compile-time verification caught every deliberately-broken graph during PoC development (missing validator binding = build error) — behaviour equivalent to `Verify()` but earlier. +6. **Trimmed self-contained deployment** (the actual ambition per §8.1: `PublishTrimmed=true`, `TrimMode=full`, self-contained linux-x64, .NET 10) — both PoCs **pass all checks**: + * **Pure.DI: zero trim warnings** — the generated composition is plain constructor calls, nothing for the trimmer to break. Startup 0.09 s / ~20 MB RSS. + * **MEDI+Injectio: works only thanks to our generated roots** (§6.3) and still emits `IL2026` from Injectio's `DecorateOpenGeneric` (`RequiresUnreferencedCode`) — i.e. trim-*unsafe by declaration*, kept alive by side-channel annotations an Ark generator must always emit correctly. Startup 0.20 s / ~22 MB RSS. + * Output size is equal (~23 MB, dominated by the runtime); the differentiator for the "trimmed + fast startup" goal is warning-free trimming and startup time, both favouring Pure.DI. +7. **ASP.NET Core cross-wiring on Pure.DI (F10)** — `evaluations/PureDiWeb.Evaluation`, a Kestrel app that self-checks over real HTTP: + * `Pure.DI.MS` `ServiceProviderFactory` works as advertised: **controller activation from the composition** (`.Roots()` + `AddControllersAsServices()`), health checks resolving composition services, minimal-API endpoints resolving the decorated handler pipeline via `IServiceProvider`, and two-way wiring (framework `ILogger` injected into composition-managed handlers). All pass on CoreCLR. + * **Hard container-independent finding: MVC controllers cannot run trimmed at all.** `AddControllers()` is `[RequiresUnreferencedCode]` ("MVC does not currently support trimming or native AOT"); discovery uses `Assembly.DefinedTypes` (trimmed → 404); even rooting the app assembly, `MapControllers()` throws `NotSupportedException` ("`IsConvertibleType` is not initialized when `Microsoft.AspNetCore.Mvc.ApiExplorer.IsEnhancedModelMetadataSupported` is false") under `TrimMode=full` on .NET 10. **The trimmed web story is Minimal APIs** — the trimmed PoC path (minimal API + health checks + composition pipeline) passes with 0.92 s including full HTTP self-checks. Consequence: `Ark.Tools.AspNetCore`'s controller-based `ArkStartupWebApi*` cannot be the trimmed-deployment surface regardless of container choice; a minimal-API-based startup flavour is a prerequisite and belongs in the migration plan (§7). +8. **Zero-manual-registration open-generic decoration is proven with an Ark-owned generator (§8.4 requirement)** — `evaluations/HandlerGen.Generator` + `evaluations/HandlerGen.Evaluation`: + * A ~150-LOC Roslyn incremental generator scans the compilation for `IQueryHandler<,>` implementations and emits one `AddDiscoveredHandlers()` extension: each handler registration is **already wrapped in every `[HandlerDecorator]`-marked open-generic decorator**, closed at compile time, plus closed `NullValidator` fallbacks for queries without a specific validator, plus a `Query → closed-service-type` dispatch map for the mediator. Output is plain constructor calls: **zero reflection, zero `MakeGenericType`**. + * The PoC's `NewFeatureHandler` simulates feature development: a handler class with **no registration attribute or call anywhere** is asserted registered and fully decorated. The guarantee is structural — the generator closes every decorator over every discovered handler, so a handler cannot exist un-decorated. A generated `KnownHandlers` manifest makes the guarantee assertable at startup. + * Result: **warning-free** on CoreCLR, `TrimMode=full` and NativeAOT (the generated dispatch map also removes the `IL3050` the earlier PoCs' `MakeGenericType` mediator carried). AoT binary 2.3 MB, full self-check in ~4 ms. + * **Structural constraint discovered: Roslyn source generators cannot see each other's output.** The Ark scanner therefore *cannot* emit Pure.DI `DI.Setup(...)` bindings — Pure.DI's generator runs on the same original compilation and never sees them. Consequences: on the Pure.DI path, handler-pipeline bindings must either be hand-listed in the setup (**rejected by §8.4**) or emitted by the Ark generator *outside* Pure.DI (hybrid: Ark generator owns the handler pipeline, Pure.DI owns the rest of the graph via `Pure.DI.MS` over the same `IServiceCollection`). On the MEDI path there is no conflict: the Ark generator emits `IServiceCollection` registrations directly — Injectio is not needed for handlers, and none of its `IL2026`/rooting problems (§6.3) apply. + +--- + +## 7. Recommended solution + +§8 clarifications (2026-07-02) resolved the decision inputs: the ambition is **trimmed deployment with very low startup times** (AoT is just one vehicle), runtime startup validation is sufficient (compile-time is a nice-to-have), StrongInject adoption is out, dual-container transition is fine, compile-time source-generated scanning is fine (no runtime plugin loading), `Func` auto-factories may be dropped for explicit registration but **fallback registration (F3) is non-negotiable**. + +**Decision: two-layer strategy with Pure.DI as the primary AoT/trimming container.** + +1. **Decouple Ark.Tools from the container now (container-agnostic core).** + * Replace `dynamic` dispatch in `Ark.Tools.Solid.SimpleInjector` processors with the DIM-bridge pattern (proven in PoCs) — benefits SimpleInjector users immediately and is a prerequisite for any trimmed deployment. + * Keep `Ark.Tools.Solid.SimpleInjector` for existing CoreCLR users (Q5: side-by-side transition approved); introduce `Ark.Tools.Solid.PureDI` (name TBD) alongside. +2. **Pure.DI as the trimming/AoT container.** Rationale against the clarified goals: + * *Trimmed deployment, low startup* (Q1): Pure.DI is the only candidate that trims **warning-free** — generated constructor calls leave nothing for the trimmer to guess about (§6.6). The MEDI+Injectio path is trim-unsafe by declaration (`IL2026`) and only survives via Ark-generated `DynamicDependency` roots — a permanent correctness liability exactly where trimming bugs are hardest to detect. Startup is also measurably faster (no runtime call-site graph construction). + * *Verification* (Q2): runtime validation suffices, and Pure.DI delivers the compile-time nice-to-have for free. + * *Fallback is mandatory* (Q7): proven in the PoC (exact-match binding beats `TT`-marker binding — the `RegisterConditional(..., !c.Handled)` equivalent). On MEDI, open-generic `TryAdd` fallback also works but interacts with the decorator rooting problem above. + * *Scanning via source generator* (Q6): approved — Ark owns a small generator that discovers handlers/validators at compile time, replacing `GetTypesToRegister`. **Prototyped and proven in §6.8** with a hard twist: Roslyn generators cannot chain, so the Ark generator cannot feed Pure.DI's setup DSL. Per the §8.4 requirement (no manual handler registration, guaranteed decoration), the handler pipeline is therefore **owned by the Ark generator on either path** — it emits closed decorator chains as `IServiceCollection` registrations (warning-free on trim/AoT); Pure.DI's role narrows to the rest of the object graph. + * *`Func`* (Q7): supported natively by Pure.DI anyway; explicit registration remains the guideline. +3. **MEDI-native remains the integration fallback.** ASP.NET Core hosting resolves framework services from MEDI regardless; `Pure.DI.MS` bridges the two. The cross-wiring PoC (§6.7) proved controller activation, health checks and minimal APIs all resolve from the Pure.DI composition — no blocking friction found. It also proved that **MVC itself cannot run trimmed** (container-independent), so the trimmed-deployment surface of `Ark.Tools.AspNetCore` must be a minimal-API startup flavour; the existing controller-based `ArkStartupWebApi*` stays CoreCLR-only. + +Explicitly rejected: forking SimpleInjector (§5.6), StrongInject (Q4: no), Jab (missing critical features). + +Next steps, in order: (a) Rebus `IHandlerActivator` PoC; (b) ~~Ark scanning-generator prototype~~ done (§6.8); (c) minimal-API `ArkStartup` flavour design (per §6.7); (d) migration plan — needs the Q3 answer (§8.3). + +--- + +## 8. Clarifications received (2026-07-02) + +Answers from @AndreaCuneo on the PR; recorded here so the decision trail is self-contained. + +1. **Scope of AoT ambition** → *"ambition is trimmed deployment with very low startup times. AoT is just a way to achieve it."* Target is `PublishTrimmed` + fast startup; NativeAOT is optional. Both PoCs re-verified under `TrimMode=full` (§6.6). +2. **Compile-time verification** → *"runtime at-startup validation is sufficient, but compile-time verification is a much interesting nice-to-have addition."* Not a hard requirement; Pure.DI provides it anyway. +3. **Migration tolerance** → *"unclear, explain"* — **still open**, expanded below (§8.3). +4. **StrongInject adoption** → **no**. Ruled out. +5. **Dual-container transition** → **yes**. `Ark.Tools.Solid.SimpleInjector` (CoreCLR) and the new trimming-ready package ship side-by-side for several versions. +6. **Compile-time-visible handlers** → **yes**. *"source generator can be used for scanning helpers at compile time. there is no need for runtime plugin-load."* Runtime assembly loading of handlers is out of scope. +7. **`Func`/variance extensions** → used, but replaceable via source generation. *"auto `Func` factory registration can be dropped for explicit registration; Fallback cannot be dropped."* → F3 (fallback/conditional registration) is **non-negotiable**; F9 auto-factories are droppable. + +### 8.3 Q3 expanded: what "migration tolerance" means + +Today an application's composition root looks like this (imperative, runtime registration — `samples/.../ApiHost.cs`): + +```csharp +container.RegisterDecorator(typeof(IQueryHandler<,>), typeof(PolicyAuthorizeQueryDecorator<,>)); +container.RegisterConditional(typeof(IValidator<>), typeof(NullValidator<>), c => !c.Handled); +container.Register(typeof(IQueryHandler<,>), Assembly.GetExecutingAssembly()); +``` + +Every source-generated container needs the registrations to be *statically analyzable*, so this exact API shape cannot survive. The question is **how different the replacement is allowed to look** in application code. Two ends of the spectrum: + +* **(a) New declarative surface (cheaper for Ark, bigger app diff).** Applications rewrite their composition root into whatever the chosen tool dictates — a Pure.DI `partial class` + fluent `DI.Setup(...)` DSL, and/or attributes on handlers (`[QueryHandler]`). Migration = rewrite of each app's `ApiHost` registration section (typically 50–150 lines per app), mechanical but manual. +* **(b) Near-source-compatible facade (costlier for Ark, near-zero app diff).** Ark ships an API that *looks like* today's `container.Register*` calls, but is actually a compile-time DSL interpreted by an Ark-owned generator (calls must be literal — no loops/conditionals around registrations). Preserves muscle memory and keeps diffs tiny, but Ark owns a mini-language + analyzer forever, and the "looks imperative, is actually compile-time" duality is itself a source of magic/confusion. + +Recommendation embedded in §7 assumes **(a)** — new declarative surface with an Ark scanning generator to keep per-app boilerplate minimal. **Please confirm (a) is acceptable, or state that (b)-style source compatibility is required.** + +### 8.4 Q3 follow-up (2026-07-02): open-generic decoration is a hard requirement + +> *"OpenGeneric Decorator support is maintained. It is not acceptable that each Handler needs to be manually registered (use source generators) and that all matching are decorated. The goal is that during feature development, new Handlers are created and those must be guaranteed to be Decorated with all registered decorators."* + +Constraints this adds to the decision: + +1. **No per-handler registration code** — not even a per-class attribute is the spirit of the ask: implementing `IQueryHandler<,>` must be sufficient. This disqualifies both the hand-listed Pure.DI `.Bind<...>()` style of `PureDi.Evaluation` and the `[RegisterSingleton]`-per-class style of `MediInjectio.Evaluation` as final shapes; both were feature probes, not the proposed surface. +2. **Decoration must be guaranteed, not conventional** — every discovered handler is wrapped by *all* registered decorators by construction. + +Addressed by the Ark-owned scanning generator PoC (§6.8, `evaluations/HandlerGen.*`): compile-time discovery of every `IQueryHandler<,>` implementation, registrations emitted pre-wrapped in the full decorator chain, structural guarantee assertable via the generated manifest, warning-free on trimmed and NativeAOT deployments. + +--- + +## 9. Sources + +* SimpleInjector issues [#1013](https://github.com/simpleinjector/SimpleInjector/issues/1013), [#972](https://github.com/simpleinjector/SimpleInjector/issues/972), [#914](https://github.com/simpleinjector/SimpleInjector/issues/914), [#264](https://github.com/simpleinjector/SimpleInjector/issues/264) +* Pure.DI: https://github.com/DevTeam/Pure.DI (generics: `readme/generics.md`, decorator scenario, `samples/ShroedingersCatNativeAOT`, `Pure.DI.MS`) +* Injectio: https://github.com/loresoft/Injectio (README "Open-generic decoration" caveat) +* Jab: https://github.com/pakrym/jab · StrongInject: https://github.com/YairHalberstadt/stronginject +* MEDI AoT internals: `dotnet/runtime` `ServiceProvider.cs`, `CallSiteFactory.CreateOpenGeneric` (value-type caveat), `ILEmitResolverBuilder` (`RequiresDynamicCode`) +* Local proofs: [`evaluations/`](../evaluations/README.md) diff --git a/evaluations/Directory.Build.props b/evaluations/Directory.Build.props new file mode 100644 index 000000000..163a28115 --- /dev/null +++ b/evaluations/Directory.Build.props @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + false + true + + diff --git a/evaluations/Directory.Build.targets b/evaluations/Directory.Build.targets new file mode 100644 index 000000000..61f7325df --- /dev/null +++ b/evaluations/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/evaluations/Directory.Packages.props b/evaluations/Directory.Packages.props new file mode 100644 index 000000000..4e1ec4d0d --- /dev/null +++ b/evaluations/Directory.Packages.props @@ -0,0 +1,6 @@ + + + + false + + diff --git a/evaluations/HandlerGen.Evaluation/HandlerGen.Evaluation.csproj b/evaluations/HandlerGen.Evaluation/HandlerGen.Evaluation.csproj new file mode 100644 index 000000000..ab694db2e --- /dev/null +++ b/evaluations/HandlerGen.Evaluation/HandlerGen.Evaluation.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + enable + enable + true + + + + + true + + + true + full + true + + + + + + + + diff --git a/evaluations/HandlerGen.Evaluation/Program.cs b/evaluations/HandlerGen.Evaluation/Program.cs new file mode 100644 index 000000000..7e9443a1a --- /dev/null +++ b/evaluations/HandlerGen.Evaluation/Program.cs @@ -0,0 +1,171 @@ +// Evaluation PoC: OPEN GENERIC DECORATION with ZERO manual handler registration. +// Review requirement (Q3 follow-up): new handlers created during feature development must be +// automatically registered AND guaranteed to be wrapped by ALL registered decorators. +// +// How: HandlerGen.Generator (an Ark-owned Roslyn incremental generator, ~150 LOC) scans this +// compilation for IQueryHandler<,> implementations and emits `AddDiscoveredHandlers()` with a +// statically-closed decorator chain per handler + closed fallback validators. Look at the classes +// below: NO registration attribute, NO Bind/Add call per handler exists anywhere in this project. +// NewFeatureHandler simulates "developer adds a handler and touches nothing else" — asserted decorated. +// +// Run with: dotnet run | AOT: dotnet publish -c Release -p:PublishAot=true +// Inspect emitted code: obj/Debug/net10.0/generated/.../GeneratedHandlerRegistrations.g.cs + +using HandlerGen.Evaluation; + +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +services.AddSingleton(); + +// ONE call, generated: all handlers, all decorators, all fallback validators +services.AddDiscoveredHandlers(); + +var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); + +var processor = new QueryProcessor(provider); + +// (1) decorator chain applied: Audit(Validation(handler)) +var r1 = await processor.ExecuteAsync(new PingQuery("hello"), CancellationToken.None); +Assert(r1 == "AUDITED(VALIDATED(pong:hello))", "discovered handler wrapped by full decorator chain"); + +// (2) value-type TResult on AOT + fallback validator (no PingValidator-like class for CountQuery) +var r2 = await processor.ExecuteAsync(new CountQuery(21), CancellationToken.None); +Assert(r2 == 42, "decorator chain on int handler (value-type TResult)"); +Assert(provider.GetRequiredService>() is NullValidator, "compile-time fallback validator emitted"); +Assert(provider.GetRequiredService>() is PingValidator, "specific validator wins over fallback"); + +// (3) THE GUARANTEE: NewFeatureHandler was added with zero registration code anywhere, +// yet it is registered and decorated by all registered decorators +var r3 = await processor.ExecuteAsync(new NewFeatureQuery(), CancellationToken.None); +Assert(r3 == "AUDITED(VALIDATED(new-feature))", "NEW handler auto-registered AND auto-decorated, no registration code"); + +// (4) structural guarantee is assertable: the generated manifest covers every handler class +Assert(GeneratedHandlerRegistrations.KnownHandlers.Count == 3, "manifest lists every discovered handler"); +Assert(GeneratedHandlerRegistrations.KnownHandlers.Any(k => k.Handler == typeof(NewFeatureHandler)), "manifest includes the new handler"); + +// (5) validation decorator is live (specific validator actually invoked) +try +{ + await processor.ExecuteAsync(new PingQuery(""), CancellationToken.None); + Assert(false, "validator must reject empty message"); +} +catch (ArgumentException) +{ + Assert(true, "validation decorator invoked the specific validator"); +} + +Console.WriteLine("ALL CHECKS PASSED"); +return 0; + +static void Assert(bool condition, string what) +{ + if (!condition) throw new InvalidOperationException($"FAILED: {what}"); + Console.WriteLine($" ok: {what}"); +} + +namespace HandlerGen.Evaluation +{ + // ---- Ark.Tools.Solid-like abstractions ---- + public interface IQuery { } + + public interface IQueryHandlerBase + { + Task ExecuteAsync(IQuery query, CancellationToken ctk); + } + + public interface IQueryHandler : IQueryHandlerBase where TQuery : IQuery + { + Task ExecuteAsync(TQuery query, CancellationToken ctk); + + Task IQueryHandlerBase.ExecuteAsync(IQuery query, CancellationToken ctk) + => ExecuteAsync((TQuery)query, ctk); + } + + public interface IValidator { void Validate(T instance); } + public sealed class NullValidator : IValidator { public void Validate(T instance) { } } + + public interface IAuditSink { void Record(string what); } + public sealed class ConsoleAuditSink : IAuditSink { public void Record(string what) => Console.WriteLine($" audit: {what}"); } + + // marks a class as an open-generic decorator applied to EVERY discovered handler; lower order = innermost + [AttributeUsage(AttributeTargets.Class)] + public sealed class HandlerDecoratorAttribute : Attribute + { + public HandlerDecoratorAttribute(int order) { Order = order; } + public int Order { get; } + } + + public sealed record PingQuery(string Message) : IQuery; + public sealed record CountQuery(int Value) : IQuery; + public sealed record NewFeatureQuery() : IQuery; + + // ---- handlers: note there is NO registration attribute/call anywhere ---- + public sealed class PingHandler : IQueryHandler + { + public Task ExecuteAsync(PingQuery query, CancellationToken ctk) => Task.FromResult($"pong:{query.Message}"); + } + + public sealed class CountHandler : IQueryHandler + { + public Task ExecuteAsync(CountQuery query, CancellationToken ctk) => Task.FromResult(query.Value * 2); + } + + // "feature development" handler: added later, zero registration code, must come out decorated + public sealed class NewFeatureHandler : IQueryHandler + { + public Task ExecuteAsync(NewFeatureQuery query, CancellationToken ctk) => Task.FromResult("new-feature"); + } + + public sealed class PingValidator : IValidator + { + public void Validate(PingQuery instance) { if (string.IsNullOrEmpty(instance.Message)) throw new ArgumentException("empty", nameof(instance)); } + } + + // ---- open-generic decorators, applied to ALL handlers by the generator ---- + [HandlerDecorator(1)] + public sealed class ValidationDecorator : IQueryHandler where TQuery : IQuery + { + private readonly IQueryHandler _inner; + private readonly IValidator _validator; + public ValidationDecorator(IQueryHandler inner, IValidator validator) + { _inner = inner; _validator = validator; } + + public async Task ExecuteAsync(TQuery query, CancellationToken ctk) + { + _validator.Validate(query); + var res = await _inner.ExecuteAsync(query, ctk); + return res is string s ? (TResult)(object)$"VALIDATED({s})" : res; + } + } + + [HandlerDecorator(2)] + public sealed class AuditDecorator : IQueryHandler where TQuery : IQuery + { + private readonly IQueryHandler _inner; + private readonly IAuditSink _sink; + public AuditDecorator(IQueryHandler inner, IAuditSink sink) { _inner = inner; _sink = sink; } + + public async Task ExecuteAsync(TQuery query, CancellationToken ctk) + { + _sink.Record(typeof(TQuery).Name); + var res = await _inner.ExecuteAsync(query, ctk); + return res is string s ? (TResult)(object)$"AUDITED({s})" : res; + } + } + + // SimpleInjectorQueryProcessor equivalent, no `dynamic` + public sealed class QueryProcessor + { + private readonly IServiceProvider _provider; + public QueryProcessor(IServiceProvider provider) { _provider = provider; } + + public async Task ExecuteAsync(IQuery query, CancellationToken ctk) + { + // generated dispatch map instead of MakeGenericType: zero IL3050 on NativeAOT + var handlerType = GeneratedHandlerRegistrations.HandlerServiceByQuery[query.GetType()]; + var handler = (IQueryHandlerBase)_provider.GetRequiredService(handlerType); + return await handler.ExecuteAsync(query, ctk); + } + } +} diff --git a/evaluations/HandlerGen.Generator/HandlerGen.Generator.csproj b/evaluations/HandlerGen.Generator/HandlerGen.Generator.csproj new file mode 100644 index 000000000..e6b5429da --- /dev/null +++ b/evaluations/HandlerGen.Generator/HandlerGen.Generator.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + latest + enable + disable + true + true + false + + + + + + + diff --git a/evaluations/HandlerGen.Generator/HandlerRegistrationGenerator.cs b/evaluations/HandlerGen.Generator/HandlerRegistrationGenerator.cs new file mode 100644 index 000000000..1762fbdec --- /dev/null +++ b/evaluations/HandlerGen.Generator/HandlerRegistrationGenerator.cs @@ -0,0 +1,155 @@ +// Evaluation PoC: Ark-owned incremental source generator = "GetTypesToRegister + RegisterDecorator at compile time". +// Answers review requirement: NO manual per-handler registration; every IQueryHandler<,> implementation in the +// compilation is discovered and its registration is emitted ALREADY WRAPPED in the full open-generic decorator chain. +// A newly added handler class is *guaranteed* decorated: the guarantee is structural (the generator closes each +// decorator over each discovered handler), not conventional. +// +// Also emits the mandatory conditional-fallback pattern statically: +// - specific IValidator implementations are registered; +// - for every handler query without a specific validator, a closed NullValidator is emitted. +// Output is plain constructor calls: zero reflection, zero MakeGenericType => NativeAOT/trim clean by construction. + +using Microsoft.CodeAnalysis; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace HandlerGen.Generator +{ + [Generator] + public sealed class HandlerRegistrationGenerator : IIncrementalGenerator + { + // ponytail: type names hardcoded to the evaluation namespace; a production generator would + // locate the Ark.Tools.Solid abstractions by metadata name from the referenced package. + private const string _ns = "HandlerGen.Evaluation"; + private static readonly SymbolDisplayFormat _fqn = SymbolDisplayFormat.FullyQualifiedFormat; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // ponytail: CompilationProvider = full re-scan per keystroke; production would use ForAttributeWithMetadataName + // and a syntax provider keyed on base-lists for incrementality. + context.RegisterSourceOutput(context.CompilationProvider, static (spc, compilation) => + { + var handlerIface = compilation.GetTypeByMetadataName(_ns + ".IQueryHandler`2"); + var validatorIface = compilation.GetTypeByMetadataName(_ns + ".IValidator`1"); + var nullValidator = compilation.GetTypeByMetadataName(_ns + ".NullValidator`1"); + var decoratorAttr = compilation.GetTypeByMetadataName(_ns + ".HandlerDecoratorAttribute"); + if (handlerIface is null || validatorIface is null || nullValidator is null || decoratorAttr is null) + return; + + var allTypes = _allTypes(compilation.Assembly.GlobalNamespace).ToList(); + + // handlers: concrete, non-generic classes implementing IQueryHandler + var handlers = allTypes + .Where(t => t.TypeKind == TypeKind.Class && !t.IsAbstract && !t.IsGenericType) + .SelectMany(t => t.AllInterfaces + .Where(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, handlerIface)) + .Select(i => (Impl: t, Query: i.TypeArguments[0], Result: i.TypeArguments[1]))) + .OrderBy(h => h.Impl.Name, StringComparer.Ordinal) + .ToList(); + + // decorators: open-generic arity-2 classes marked [HandlerDecorator(order)] + var decorators = allTypes + .Where(t => t.TypeKind == TypeKind.Class && t.IsGenericType && t.Arity == 2) + .Select(t => (Impl: t, Attr: t.GetAttributes().FirstOrDefault(a => + SymbolEqualityComparer.Default.Equals(a.AttributeClass, decoratorAttr)))) + .Where(t => t.Attr is not null) + .Select(t => (t.Impl, Order: (int)t.Attr!.ConstructorArguments[0].Value!)) + .OrderBy(t => t.Order) // innermost first + .ToList(); + + // specific validators: concrete non-generic classes implementing IValidator + var validators = allTypes + .Where(t => t.TypeKind == TypeKind.Class && !t.IsAbstract && !t.IsGenericType) + .SelectMany(t => t.AllInterfaces + .Where(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, validatorIface)) + .Select(i => (Impl: t, Subject: i.TypeArguments[0]))) + .ToList(); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine("namespace " + _ns); + sb.AppendLine("{"); + sb.AppendLine(" public static class GeneratedHandlerRegistrations"); + sb.AppendLine(" {"); + + // manifest: lets the app assert the "every handler is registered+decorated" guarantee + sb.AppendLine(" public static readonly global::System.Collections.Generic.IReadOnlyList<(global::System.Type Query, global::System.Type Result, global::System.Type Handler)> KnownHandlers = new (global::System.Type, global::System.Type, global::System.Type)[]"); + sb.AppendLine(" {"); + foreach (var h in handlers) + sb.AppendLine($" (typeof({h.Query.ToDisplayString(_fqn)}), typeof({h.Result.ToDisplayString(_fqn)}), typeof({h.Impl.ToDisplayString(_fqn)})),"); + sb.AppendLine(" };"); + sb.AppendLine(); + + // dispatch map: query type -> closed handler service type; replaces the mediator's + // MakeGenericType call (IL3050) with a warning-free static lookup + sb.AppendLine(" public static readonly global::System.Collections.Generic.IReadOnlyDictionary HandlerServiceByQuery = new global::System.Collections.Generic.Dictionary"); + sb.AppendLine(" {"); + foreach (var h in handlers) + sb.AppendLine($" [typeof({h.Query.ToDisplayString(_fqn)})] = typeof({handlerIface.Construct((INamedTypeSymbol)h.Query, (INamedTypeSymbol)h.Result).ToDisplayString(_fqn)}),"); + sb.AppendLine(" };"); + sb.AppendLine(); + sb.AppendLine(" public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddDiscoveredHandlers(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services)"); + sb.AppendLine(" {"); + + // (a) specific validators, then closed fallbacks (RegisterConditional equivalent, resolved at compile time) + var covered = new HashSet(SymbolEqualityComparer.Default); + foreach (var v in validators) + { + covered.Add(v.Subject); + sb.AppendLine($" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient<{validatorIface.Construct((INamedTypeSymbol)v.Subject).ToDisplayString(_fqn)}, {v.Impl.ToDisplayString(_fqn)}>(services);"); + } + foreach (var q in handlers.Select(h => h.Query).Distinct(SymbolEqualityComparer.Default).Cast()) + { + if (covered.Contains(q)) continue; + sb.AppendLine($" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient<{validatorIface.Construct((INamedTypeSymbol)q).ToDisplayString(_fqn)}, {nullValidator.Construct((INamedTypeSymbol)q).ToDisplayString(_fqn)}>(services);"); + } + + // (b) handlers, each wrapped in EVERY registered decorator (open-generic decoration, closed at compile time) + foreach (var h in handlers) + { + var service = handlerIface.Construct((INamedTypeSymbol)h.Query, (INamedTypeSymbol)h.Result).ToDisplayString(_fqn); + var expr = _newExpr(h.Impl, inner: null, service); + foreach (var d in decorators) + expr = _newExpr(d.Impl.Construct((INamedTypeSymbol)h.Query, (INamedTypeSymbol)h.Result), expr, service); + sb.AppendLine($" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient<{service}>(services, sp => {expr});"); + } + + sb.AppendLine(" return services;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + spc.AddSource("GeneratedHandlerRegistrations.g.cs", sb.ToString()); + }); + } + + // builds `new T(arg, arg, ...)`: the inner-handler ctor parameter receives `inner`, + // every other dependency is a statically-typed sp.GetRequiredService() call + private static string _newExpr(INamedTypeSymbol type, string? inner, string serviceFqn) + { + var ctor = type.InstanceConstructors + .Where(c => c.DeclaredAccessibility == Accessibility.Public) + .OrderByDescending(c => c.Parameters.Length) + .First(); + var args = ctor.Parameters.Select(p => + inner is not null && p.Type.ToDisplayString(_fqn) == serviceFqn + ? inner + : $"global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<{p.Type.ToDisplayString(_fqn)}>(sp)"); + return $"new {type.ToDisplayString(_fqn)}({string.Join(", ", args)})"; + } + + private static IEnumerable _allTypes(INamespaceSymbol ns) + { + foreach (var m in ns.GetMembers()) + { + if (m is INamespaceSymbol child) + foreach (var t in _allTypes(child)) yield return t; + else if (m is INamedTypeSymbol t) + yield return t; + } + } + } +} diff --git a/evaluations/MediInjectio.Evaluation/MediInjectio.Evaluation.csproj b/evaluations/MediInjectio.Evaluation/MediInjectio.Evaluation.csproj new file mode 100644 index 000000000..79327a981 --- /dev/null +++ b/evaluations/MediInjectio.Evaluation/MediInjectio.Evaluation.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/evaluations/MediInjectio.Evaluation/Program.cs b/evaluations/MediInjectio.Evaluation/Program.cs new file mode 100644 index 000000000..8963cb954 --- /dev/null +++ b/evaluations/MediInjectio.Evaluation/Program.cs @@ -0,0 +1,205 @@ +// Evaluation PoC: Microsoft.Extensions.DependencyInjection (MEDI) + Injectio source generator +// Verifies the SimpleInjector feature-set Ark.Tools relies upon: +// 1. compile-time batch registration of handlers (Injectio attributes ~ GetTypesToRegister) +// 2. OPEN GENERIC DECORATORS applied to handlers (RegisterDecorator(typeof(IQueryHandler<,>), ...)) +// 3. conditional fallback registration (RegisterConditional NullValidator<> when no specific validator) +// 4. runtime mediator dispatch: GetRequiredService(typeof(IQueryHandler<,>).MakeGenericType(...)) +// using an AOT-safe non-generic bridge instead of `dynamic` (dynamic requires dynamic code) +// 5. scoped lifetimes + ValidateOnBuild/ValidateScopes (SimpleInjector Verify()-lite) +// 6. collection resolution (GetServices ~ GetAllInstances) +// Run with: dotnet run | AOT: dotnet publish -c Release -p:PublishAot=true + +using MediInjectio.Evaluation; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +// (7) NativeAOT FINDING: Injectio's open-generic decoration closes decorator types at runtime +// (ActivatorUtilities). On NativeAOT the closed instantiations (and their ctor native code) don't +// exist unless something references them statically. GeneratedAotRoots simulates what a small +// Ark-owned source generator would emit: one closed root per (handler x decorator) pair. +// Without this call the AOT-published binary crashes with +// "A suitable constructor for type 'ValidationDecorator`2[PingQuery,String]' could not be located". +GeneratedAotRoots.Root(); + +var services = new ServiceCollection(); + +// (1) Injectio generated extension: registers all [Register*] annotated types of this assembly +services.AddMediInjectioEvaluation(); + +// (3) conditional fallback: open-generic NullValidator<> only used when no closed validator exists. +services.TryAdd(ServiceDescriptor.Singleton(typeof(IValidator<>), typeof(NullValidator<>))); + +var provider = services.BuildServiceProvider(new ServiceProviderOptions +{ + // (5) SimpleInjector Verify()-lite: builds every registration eagerly and validates scopes + ValidateOnBuild = true, + ValidateScopes = true, +}); + +using (var scope = provider.CreateScope()) +{ + // (4) runtime mediator dispatch, same pattern as SimpleInjectorQueryProcessor + var scoped = new QueryProcessor(scope.ServiceProvider); + + // PingQuery has a real validator, decorator chain must be Audit(Validation(handler)) + var r1 = await scoped.ExecuteAsync(new PingQuery("hello"), CancellationToken.None); + Console.WriteLine($"Ping => {r1}"); + // CountQuery has no validator -> fallback NullValidator must kick in + var r2 = await scoped.ExecuteAsync(new CountQuery(21), CancellationToken.None); + Console.WriteLine($"Count => {r2}"); + + // assertions = the actual proof + Assert(r1 == "AUDITED(VALIDATED(pong:hello))", "open-generic decorator chain on string handler"); + Assert(r2 == 42, "open-generic decorator chain on int handler (value-type TResult on AOT)"); + Assert(scope.ServiceProvider.GetRequiredService>() is PingValidator, "specific validator wins over fallback"); + Assert(scope.ServiceProvider.GetRequiredService>() is NullValidator, "fallback open-generic validator"); + + // (6) GetAllInstances equivalent (Rebus IHandleMessages pattern) + var handlers = scope.ServiceProvider.GetServices>().ToList(); + Assert(handlers.Count == 2, "collection resolution of message handlers"); + + // scoped lifetime identity within scope + var c1 = scope.ServiceProvider.GetRequiredService(); + var c2 = scope.ServiceProvider.GetRequiredService(); + Assert(ReferenceEquals(c1, c2), "scoped lifetime identity"); +} + +// Func factory: NOT supported natively by MEDI (SimpleInjector AllowResolvingFuncFactories has no equivalent) +Assert(provider.GetService>() is null, "Func auto-factory NOT available in MEDI (known gap)"); + +Console.WriteLine("ALL CHECKS PASSED"); +return 0; + +static void Assert(bool condition, string what) +{ + if (!condition) throw new InvalidOperationException($"FAILED: {what}"); + Console.WriteLine($" ok: {what}"); +} + +namespace MediInjectio.Evaluation +{ + using Injectio.Attributes; + + using System.Diagnostics.CodeAnalysis; + + using Microsoft.Extensions.DependencyInjection; + + // ---- Ark.Tools.Solid-like abstractions ---- + public interface IQuery { } + + // AOT-safe replacement of the `dynamic` dispatch used by SimpleInjectorQueryProcessor: + // non-generic-in-TQuery bridge implemented via default interface method. + public interface IQueryHandlerBase + { + Task ExecuteAsync(IQuery query, CancellationToken ctk); + } + + public interface IQueryHandler : IQueryHandlerBase where TQuery : IQuery + { + Task ExecuteAsync(TQuery query, CancellationToken ctk); + + Task IQueryHandlerBase.ExecuteAsync(IQuery query, CancellationToken ctk) + => ExecuteAsync((TQuery)query, ctk); + } + + public interface IValidator { void Validate(T instance); } + public interface IHandleMessages { Task Handle(TMessage message); } + public interface IDbConnectionManager { } + + public sealed record PingQuery(string Message) : IQuery; + public sealed record CountQuery(int Value) : IQuery; + + // (1) compile-time batch registration by Injectio (closed interface registrations) + [RegisterSingleton(Registration = RegistrationStrategy.ImplementedInterfaces)] + public sealed class PingHandler : IQueryHandler + { + public Task ExecuteAsync(PingQuery query, CancellationToken ctk) => Task.FromResult($"pong:{query.Message}"); + } + + [RegisterSingleton(Registration = RegistrationStrategy.ImplementedInterfaces)] + public sealed class CountHandler : IQueryHandler + { + public Task ExecuteAsync(CountQuery query, CancellationToken ctk) => Task.FromResult(query.Value * 2); + } + + [RegisterSingleton(Registration = RegistrationStrategy.ImplementedInterfaces)] + public sealed class PingValidator : IValidator + { + public void Validate(PingQuery instance) { if (string.IsNullOrEmpty(instance.Message)) throw new ArgumentException("empty"); } + } + + public sealed class NullValidator : IValidator { public void Validate(T instance) { } } + + // (2) open-generic decorators over the closed handler registrations, innermost first + [RegisterDecorator(ServiceType = typeof(IQueryHandler<,>), Order = 1)] + public sealed class ValidationDecorator : IQueryHandler where TQuery : IQuery + { + private readonly IQueryHandler _inner; + private readonly IValidator _validator; + public ValidationDecorator(IQueryHandler inner, IValidator validator) + { _inner = inner; _validator = validator; } + + public async Task ExecuteAsync(TQuery query, CancellationToken ctk) + { + _validator.Validate(query); + var res = await _inner.ExecuteAsync(query, ctk); + return res is string s ? (TResult)(object)$"VALIDATED({s})" : res; + } + } + + [RegisterDecorator(ServiceType = typeof(IQueryHandler<,>), Order = 2)] + public sealed class AuditDecorator : IQueryHandler where TQuery : IQuery + { + private readonly IQueryHandler _inner; + public AuditDecorator(IQueryHandler inner) { _inner = inner; } + + public async Task ExecuteAsync(TQuery query, CancellationToken ctk) + { + var res = await _inner.ExecuteAsync(query, ctk); + return res is string s ? (TResult)(object)$"AUDITED({s})" : res; + } + } + + // (6) multiple handlers for the same message (Rebus pattern) + [RegisterSingleton(Registration = RegistrationStrategy.ImplementedInterfaces, Duplicate = DuplicateStrategy.Append)] + public sealed class StringHandlerA : IHandleMessages { public Task Handle(string message) => Task.CompletedTask; } + [RegisterSingleton(Registration = RegistrationStrategy.ImplementedInterfaces, Duplicate = DuplicateStrategy.Append)] + public sealed class StringHandlerB : IHandleMessages { public Task Handle(string message) => Task.CompletedTask; } + + [RegisterScoped(Registration = RegistrationStrategy.ImplementedInterfaces)] + public sealed class FakeConnectionManager : IDbConnectionManager { } + + // (7) what an Ark source generator would emit to make runtime-closed decorators AOT-safe: + // roots the reflection metadata (public ctors) of the closed generic instantiations, + // since Injectio closes decorators at runtime via ActivatorUtilities (reflection). + public static class GeneratedAotRoots + { + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(ValidationDecorator))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(AuditDecorator))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(ValidationDecorator))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(AuditDecorator))] + public static void Root() + { + // value-type generic args need the actual native instantiation compiled by ILC + // (reference-type args share the __Canon instantiation, metadata rooting suffices) + _ = new ValidationDecorator(null!, null!); + _ = new AuditDecorator(null!); + } + } + + // (4) SimpleInjectorQueryProcessor equivalent on MEDI, without `dynamic` + public sealed class QueryProcessor + { + private readonly IServiceProvider _provider; + public QueryProcessor(IServiceProvider provider) { _provider = provider; } + + public async Task ExecuteAsync(IQuery query, CancellationToken ctk) + { + // MakeGenericType over an instantiation that exists in compiled code is AOT-safe + var handlerType = typeof(IQueryHandler<,>).MakeGenericType(query.GetType(), typeof(TResult)); + var handler = (IQueryHandlerBase)_provider.GetRequiredService(handlerType); + return await handler.ExecuteAsync(query, ctk); + } + } +} diff --git a/evaluations/PureDi.Evaluation/Program.cs b/evaluations/PureDi.Evaluation/Program.cs new file mode 100644 index 000000000..bbb0cdd10 --- /dev/null +++ b/evaluations/PureDi.Evaluation/Program.cs @@ -0,0 +1,178 @@ +// Evaluation PoC: Pure.DI source-generated composition +// Verifies the SimpleInjector feature-set Ark.Tools relies upon: +// 1. open-generic registration via TT marker types (compile-time monomorphization) +// 2. OPEN GENERIC DECORATORS via TT markers + tag-chained bindings +// 3. conditional fallback registration (exact IValidator binding wins over IValidator) +// 4. runtime mediator dispatch via generated Resolve(Type) over per-handler roots +// 5. scoped lifetime via child composition (Session pattern) +// 6. Func factory resolution +// Compile-time verification: any missing dependency is a BUILD ERROR (SimpleInjector Verify() at compile time). +// Run with: dotnet run | AOT: dotnet publish -c Release -p:PublishAot=true + +using PureDi.Evaluation; + +var composition = new Composition(); + +// (4) runtime mediator dispatch, same pattern as SimpleInjectorQueryProcessor +var processor = new QueryProcessor(composition); + +var r1 = await processor.ExecuteAsync(new PingQuery("hello"), CancellationToken.None); +Console.WriteLine($"Ping => {r1}"); +var r2 = await processor.ExecuteAsync(new CountQuery(21), CancellationToken.None); +Console.WriteLine($"Count => {r2}"); + +Assert(r1 == "AUDITED(VALIDATED(pong:hello))", "open-generic decorator chain on string handler"); +Assert(r2 == 42, "open-generic decorator chain on int handler (value-type TResult on AOT)"); + +// (3) exact-match binding wins over TT fallback +Assert(composition.PingValidator is PingValidator, "specific validator wins over fallback"); +Assert(composition.CountValidator is NullValidator, "fallback open-generic validator"); + +// (5) scoped lifetime: same instance within a scope, different across scopes +{ + var s1 = new Session(composition); + var s2 = new Session(composition); + Assert(ReferenceEquals(s1.Connection, s1.Connection), "scoped lifetime identity within scope"); + Assert(!ReferenceEquals(s1.Connection, s2.Connection), "different instances across scopes"); +} + +// (6) Func factory +var factory = composition.HandlerFactory; +Assert(factory() is not null, "Func factory resolution"); + +Console.WriteLine("ALL CHECKS PASSED"); +return 0; + +static void Assert(bool condition, string what) +{ + if (!condition) throw new InvalidOperationException($"FAILED: {what}"); + Console.WriteLine($" ok: {what}"); +} + +namespace PureDi.Evaluation +{ + using Pure.DI; + + // ---- Ark.Tools.Solid-like abstractions ---- + public interface IQuery { } + + // AOT-safe replacement of the `dynamic` dispatch used by SimpleInjectorQueryProcessor + public interface IQueryHandlerBase + { + Task ExecuteAsync(IQuery query, CancellationToken ctk); + } + + public interface IQueryHandler : IQueryHandlerBase where TQuery : IQuery + { + Task ExecuteAsync(TQuery query, CancellationToken ctk); + + Task IQueryHandlerBase.ExecuteAsync(IQuery query, CancellationToken ctk) + => ExecuteAsync((TQuery)query, ctk); + } + + public interface IValidator { void Validate(T instance); } + public interface IDbConnectionManager { } + + public sealed record PingQuery(string Message) : IQuery; + public sealed record CountQuery(int Value) : IQuery; + + public sealed class PingHandler : IQueryHandler + { + public Task ExecuteAsync(PingQuery query, CancellationToken ctk) => Task.FromResult($"pong:{query.Message}"); + } + + public sealed class CountHandler : IQueryHandler + { + public Task ExecuteAsync(CountQuery query, CancellationToken ctk) => Task.FromResult(query.Value * 2); + } + + public sealed class PingValidator : IValidator + { + public void Validate(PingQuery instance) { if (string.IsNullOrEmpty(instance.Message)) throw new ArgumentException("empty", nameof(instance)); } + } + + public sealed class NullValidator : IValidator { public void Validate(T instance) { } } + + // (2) open-generic decorators; tag-chained: "base" -> "validated" -> default + public sealed class ValidationDecorator : IQueryHandler where TQuery : IQuery + { + private readonly IQueryHandler _inner; + private readonly IValidator _validator; + public ValidationDecorator([Tag("base")] IQueryHandler inner, IValidator validator) + { _inner = inner; _validator = validator; } + + public async Task ExecuteAsync(TQuery query, CancellationToken ctk) + { + _validator.Validate(query); + var res = await _inner.ExecuteAsync(query, ctk); + return res is string s ? (TResult)(object)$"VALIDATED({s})" : res; + } + } + + public sealed class AuditDecorator : IQueryHandler where TQuery : IQuery + { + private readonly IQueryHandler _inner; + public AuditDecorator([Tag("validated")] IQueryHandler inner) { _inner = inner; } + + public async Task ExecuteAsync(TQuery query, CancellationToken ctk) + { + var res = await _inner.ExecuteAsync(query, ctk); + return res is string s ? (TResult)(object)$"AUDITED({s})" : res; + } + } + + public sealed class FakeConnectionManager : IDbConnectionManager { } + + // custom Pure.DI generic markers: TTQuery is constrained to IQuery, mirroring the handler constraint + [GenericTypeArgument] + internal interface TTQuery : IQuery { } + + public partial class Composition + { + private static void Setup() => + DI.Setup(nameof(Composition)) + // (1) per-handler "base" bindings (what compile-time batch registration would emit) + .Bind>("base").To() + .Bind>("base").To() + + // (2) open-generic decorator chain via TT markers, applied to EVERY handler + .Bind>("validated").To>() + .Bind>().To>() + + // (3) fallback validator via TT + exact-match override + .Bind>().To>() + .Bind>().To() + + // (5) scoped + .Bind().As(Lifetime.Scoped).To() + + // roots: private roots are resolvable via Resolve(Type) for mediator dispatch + .Root>() + .Root>() + .Root>("PingValidator") + .Root>("CountValidator") + .Root("Connection") + // (6) Func factory root + .Root>>("HandlerFactory"); + } + + // (5) scope = child composition + public sealed class Session : Composition + { + public Session(Composition parent) : base(parent) { } + } + + // (4) SimpleInjectorQueryProcessor equivalent using generated Resolve(Type) + public sealed class QueryProcessor + { + private readonly Composition _composition; + public QueryProcessor(Composition composition) { _composition = composition; } + + public async Task ExecuteAsync(IQuery query, CancellationToken ctk) + { + var handlerType = typeof(IQueryHandler<,>).MakeGenericType(query.GetType(), typeof(TResult)); + var handler = (IQueryHandlerBase)_composition.Resolve(handlerType); + return await handler.ExecuteAsync(query, ctk); + } + } +} diff --git a/evaluations/PureDi.Evaluation/PureDi.Evaluation.csproj b/evaluations/PureDi.Evaluation/PureDi.Evaluation.csproj new file mode 100644 index 000000000..5d6c27667 --- /dev/null +++ b/evaluations/PureDi.Evaluation/PureDi.Evaluation.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/evaluations/PureDiWeb.Evaluation/Program.cs b/evaluations/PureDiWeb.Evaluation/Program.cs new file mode 100644 index 000000000..f27c46413 --- /dev/null +++ b/evaluations/PureDiWeb.Evaluation/Program.cs @@ -0,0 +1,180 @@ +// Evaluation PoC: Pure.DI + ASP.NET Core cross-wiring (F10) +// Replicates what Ark.Tools.AspNetCore does with SimpleInjector's AddSimpleInjector/AddAspNetCore: +// 1. controller activation from the Pure.DI composition (Roots() + AddControllersAsServices) — CoreCLR only, see below +// 2. minimal-API endpoint consuming the DECORATED open-generic query handler pipeline from the composition +// 3. health check resolving a service from the composition (Ark.Tools.AspNetCore.HealthChecks pattern) +// 4. two-way resolution: composition classes consume framework services (ILogger) from IServiceProvider +// Self-check: the app starts Kestrel on a loopback port, calls itself over HTTP and asserts the responses. +// +// EMPIRICAL FINDING (container-independent): MVC does NOT survive trimming, full stop. +// * AddControllers() is [RequiresUnreferencedCode] "MVC does not currently support trimming or native AOT"; +// * controller discovery relies on Assembly.DefinedTypes (trimmed -> 404); +// * even with rooting the app assembly, MapControllers() throws at startup: +// NotSupportedException "IsConvertibleType is not initialized when Microsoft.AspNetCore.Mvc.ApiExplorer.IsEnhancedModelMetadataSupported is false" +// (ModelMetadata feature-switched off under TrimMode=full, .NET 10). +// The trimmed web story is Minimal APIs; this is an Ark.Tools.AspNetCore startup concern, not a DI-container concern. +// Controller activation is therefore asserted on CoreCLR only (pass --with-mvc), minimal API + health checks on both. +// Run with: dotnet run -- --with-mvc | trimmed: dotnet publish -c Release -p:PublishTrimmed=true -p:TrimMode=full --self-contained -r linux-x64 + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +using PureDiWeb.Evaluation; + +var withMvc = args.Contains("--with-mvc"); // MVC cannot run trimmed (see header) - CoreCLR-only assertion + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +builder.WebHost.UseUrls("http://127.0.0.1:0"); + +var composition = new Composition(); +builder.Host.UseServiceProviderFactory(composition); + +if (withMvc) + builder.Services.AddControllers().AddControllersAsServices(); +builder.Services.AddHealthChecks().AddCheck("composition"); + +var app = builder.Build(); +if (withMvc) + app.MapControllers(); +// minimal API endpoint resolving the composition-managed decorated pipeline (the trimmed-deployment path) +app.MapGet("/mping/{message}", async (string message, HttpContext ctx) => +{ + var handler = ctx.RequestServices.GetRequiredService>(); + return await handler.ExecuteAsync(new PingQuery(message), ctx.RequestAborted); +}); +app.MapHealthChecks("/health"); + +await app.StartAsync(); + +// ---- self-check over real HTTP ---- +var baseUrl = app.Urls.First(); +using var http = new HttpClient { BaseAddress = new Uri(baseUrl) }; + +if (withMvc) +{ + var ping = await http.GetStringAsync("/ping/hello"); + Assert(ping == "AUDITED(VALIDATED(pong:hello))", "controller activated from composition with full decorator chain"); +} + +var mping = await http.GetStringAsync("/mping/hello"); +Assert(mping == "AUDITED(VALIDATED(pong:hello))", "minimal-API endpoint resolving decorated pipeline from composition"); + +var health = await http.GetAsync("/health"); +Assert(health.IsSuccessStatusCode, "health check resolving from composition"); + +Assert(CompositionHealthCheck.Ran, "health check actually executed"); +Assert(PingHandler.LoggerWasInjected, "framework ILogger cross-wired into composition-managed handler"); + +Console.WriteLine("ALL CHECKS PASSED"); +await app.StopAsync(); +return 0; + +static void Assert(bool condition, string what) +{ + if (!condition) throw new InvalidOperationException($"FAILED: {what}"); + Console.WriteLine($" ok: {what}"); +} + +namespace PureDiWeb.Evaluation +{ + using Pure.DI; + using Pure.DI.MS; + + // ---- Ark.Tools.Solid-like abstractions (same as PureDi.Evaluation) ---- + public interface IQuery { } + + public interface IQueryHandler where TQuery : IQuery + { + Task ExecuteAsync(TQuery query, CancellationToken ctk); + } + + public interface IValidator { void Validate(T instance); } + + public sealed record PingQuery(string Message) : IQuery; + + public sealed class PingHandler : IQueryHandler + { + public static bool LoggerWasInjected; + + // ILogger is a FRAMEWORK service: Pure.DI.MS resolves it from IServiceProvider (two-way cross-wiring) + public PingHandler(ILogger logger) { LoggerWasInjected = logger is not null; } + + public Task ExecuteAsync(PingQuery query, CancellationToken ctk) => Task.FromResult($"pong:{query.Message}"); + } + + public sealed class NullValidator : IValidator { public void Validate(T instance) { } } + + public sealed class ValidationDecorator : IQueryHandler where TQuery : IQuery + { + private readonly IQueryHandler _inner; + private readonly IValidator _validator; + public ValidationDecorator([Tag("base")] IQueryHandler inner, IValidator validator) + { _inner = inner; _validator = validator; } + + public async Task ExecuteAsync(TQuery query, CancellationToken ctk) + { + _validator.Validate(query); + var res = await _inner.ExecuteAsync(query, ctk); + return res is string s ? (TResult)(object)$"VALIDATED({s})" : res; + } + } + + public sealed class AuditDecorator : IQueryHandler where TQuery : IQuery + { + private readonly IQueryHandler _inner; + public AuditDecorator([Tag("validated")] IQueryHandler inner) { _inner = inner; } + + public async Task ExecuteAsync(TQuery query, CancellationToken ctk) + { + var res = await _inner.ExecuteAsync(query, ctk); + return res is string s ? (TResult)(object)$"AUDITED({s})" : res; + } + } + + // controller resolved FROM the Pure.DI composition, consuming the decorated pipeline + [ApiController] + public sealed class PingController : ControllerBase + { + private readonly IQueryHandler _handler; + public PingController(IQueryHandler handler) { _handler = handler; } + + [HttpGet("/ping/{message}")] + public async Task Get(string message) => await _handler.ExecuteAsync(new PingQuery(message), HttpContext.RequestAborted); + } + + // health check resolving a composition service, like Ark.Tools.AspNetCore.HealthChecks adapters + public sealed class CompositionHealthCheck : IHealthCheck + { + public static bool Ran; + private readonly IValidator _validator; + public CompositionHealthCheck(IValidator validator) { _validator = validator; } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + Ran = true; + return Task.FromResult(_validator is not null ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy()); + } + } + + [GenericTypeArgument] + internal interface TTQuery : IQuery { } + + public partial class Composition : ServiceProviderFactory + { + [System.Diagnostics.Conditional("DI")] + private static void Setup() => + DI.Setup(nameof(Composition)) + + .Bind>("base").To() + .Bind>("validated").To>() + .Bind>().To>() + .Bind>().To>() + + // roots resolvable via IServiceProvider: controllers + health check + minimal-API handler + .Roots() + .Root() + .Root>() + .Root>(); + } +} diff --git a/evaluations/PureDiWeb.Evaluation/PureDiWeb.Evaluation.csproj b/evaluations/PureDiWeb.Evaluation/PureDiWeb.Evaluation.csproj new file mode 100644 index 000000000..7357ac901 --- /dev/null +++ b/evaluations/PureDiWeb.Evaluation/PureDiWeb.Evaluation.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/evaluations/README.md b/evaluations/README.md new file mode 100644 index 000000000..6c413dad3 --- /dev/null +++ b/evaluations/README.md @@ -0,0 +1,36 @@ +# Evaluation PoCs — AoT-ready DI container research + +Throw-away proof-of-concept projects supporting [docs/aot-di-container-research.md](../docs/aot-di-container-research.md). + +**Intentionally isolated** from the repository build: local `Directory.Build.props`, `Directory.Build.targets` and `Directory.Packages.props` stop inheritance of repo-wide settings (analyzers, CPM, lock files). Not referenced by `Ark.Tools.slnx`, not built by CI. + +## Projects + +| Project | Candidate | What it proves | +|---|---|---| +| `MediInjectio.Evaluation` | Microsoft.Extensions.DependencyInjection + Injectio 6.1 source generator | Open-generic decorators over closed handler registrations, fallback validator, runtime mediator dispatch, scopes, `ValidateOnBuild`. Documents the NativeAOT failure of runtime-closed decorators and the `DynamicDependency` + value-type-instantiation rooting fix a small Ark generator would emit. | +| `PureDi.Evaluation` | Pure.DI 2.4 | Same feature-set fully source-generated: TT-marker open generics with **custom constrained markers** (`TTQuery : IQuery`), tag-chained open-generic decorators, exact-match override of TT fallback bindings, scoped child compositions, `Func` roots, `Resolve(Type)` mediator dispatch. Compile-time graph verification. | +| `HandlerGen.Generator` + `HandlerGen.Evaluation` | Ark-owned Roslyn incremental generator over MEDI | **Open-generic decoration with zero manual registration** (§8.4 requirement): the generator discovers every `IQueryHandler<,>` implementation at compile time and emits registrations pre-wrapped in *all* `[HandlerDecorator]` decorators, closed fallback validators, and a query→service dispatch map. `NewFeatureHandler` proves a new handler with no registration code anywhere is guaranteed decorated. Plain constructor calls: warning-free on trimmed and NativeAOT. Also proves Roslyn generators cannot chain — the scanner cannot feed Pure.DI's setup DSL. | +| `PureDiWeb.Evaluation` | Pure.DI 2.4 + `Pure.DI.MS` | ASP.NET Core cross-wiring (F10): controller activation from the composition (`Roots()` + `AddControllersAsServices()`, CoreCLR-only — MVC cannot run trimmed, container-independent), minimal-API endpoint + health check resolving the decorated pipeline, framework `ILogger` injected into composition services. Self-checks over real HTTP; trimmed minimal-API path passes. | + +## Run + +```bash +cd evaluations/MediInjectio.Evaluation && dotnet run +cd evaluations/PureDi.Evaluation && dotnet run +cd evaluations/PureDiWeb.Evaluation && dotnet run -- --with-mvc # MVC assertions are CoreCLR-only +cd evaluations/HandlerGen.Evaluation && dotnet run # generated code: obj/*/generated/ + +# NativeAOT proof +dotnet publish -c Release -p:PublishAot=true -o /tmp/aot && /tmp/aot/ + +# Trimmed self-contained proof (the actual §8.1 target) +dotnet publish -c Release -p:PublishTrimmed=true -p:TrimMode=full --self-contained -r linux-x64 -o /tmp/trim && /tmp/trim/ + +# HandlerGen.Evaluation only: -p:PublishAot / -p:PublishTrimmed are global and would break the +# netstandard2.0 generator project; use the scoped switches instead +dotnet publish -c Release -r linux-x64 -p:AotApp=true -o /tmp/aot +dotnet publish -c Release -r linux-x64 -p:TrimApp=true -o /tmp/trim +``` + +Each program is an assert-based self-check: it exits non-zero on any regression.