Repository navigation
Unkeyed DependsOn injects null or rejects registered ServiceKey parameter types #112
Description
Priority: P2
Problem
ConstructorActivator treats every ServiceKey parameter as a request for the current key, even when the registration is unkeyed. In standard DI, ServiceKey injection applies when the effective service key is non-null; otherwise the parameter resolves ordinarily. Adding an unrelated DependsOn label override silently changes an injected string from its registered value to null, and rejects an otherwise registered integer.
Follow-up to closed #51; NativeConstructorActivator already guards ServiceKey injection with serviceKey != null, but ConstructorActivator does not.
Reproduction
Save this project as artifacts/review-unkeyed-servicekey/repro.csproj, with Program.cs beside it, in the reviewed checkout.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFrameworks>net472;net8.0;net9.0;net10.0</TargetFrameworks><LangVersion>14</LangVersion><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><WarningLevel>0</WarningLevel></PropertyGroup>
<ItemGroup><ProjectReference Include="../../src/Mammoth.Extensions.DependencyInjection/Mammoth.Extensions.DependencyInjection.csproj" /></ItemGroup>
</Project>using Mammoth.Extensions.DependencyInjection;
using Mammoth.Extensions.DependencyInjection.Configuration;
using Microsoft.Extensions.DependencyInjection;
foreach (var mapped in new[] { false, true })
{
Run("string, mapped=" + mapped, () => {
var s = new ServiceCollection(); s.AddSingleton("ordinary");
if (mapped) s.AddTransient<StringConsumer>([Dependency.OnValue("label", "configured")]);
else s.AddTransient<StringConsumer>();
using var p = s.BuildServiceProvider(); return p.GetRequiredService<StringConsumer>().Key ?? "NULL";
});
Run("int, mapped=" + mapped, () => {
var s = new ServiceCollection(); s.AddSingleton("ordinary"); s.AddSingleton(typeof(int), 42);
if (mapped) s.AddTransient<IntConsumer>([Dependency.OnValue("label", "configured")]);
else s.AddTransient<IntConsumer>();
using var p = s.BuildServiceProvider(); return p.GetRequiredService<IntConsumer>().Key.ToString();
});
}
static void Run(string name, Func<string> action) { try { Console.WriteLine(name + " => " + action()); } catch(Exception e) { Console.WriteLine(name + " => " + e.GetType().Name + ": " + e.Message); } }
public sealed class StringConsumer([ServiceKey] string key, string label) { public string? Key { get; } = key; }
public sealed class IntConsumer([ServiceKey] int key, string label) { public int Key { get; } = key; }Run dotnet run --project artifacts/review-unkeyed-servicekey/repro.csproj -c Release -f net472, then repeat with net8.0, net9.0 and net10.0.
Observed versus expected
The native paths print ordinary (string) and 42 (integer). The mapped string path prints NULL; the mapped integer path throws No satisfiable public constructor. Results match on net472/net8.0/net9.0/net10.0.
Honor ServiceKey only in a keyed context. In an unkeyed/null-key context, preserve normal registered-service and optional-default behavior, while keeping a named override for that parameter highest priority. Cover string/object/value-type ServiceKey parameters, explicit null service keys, named overrides, optional defaults and genuine non-null keyed resolutions.
Verification
Executed against ead376e with Microsoft.Extensions.DependencyInjection 10.0.0, targeting net472 on Windows .NET Framework 4.8.9345.0, .NET 8.0.31, .NET 9.0.20 and .NET 10.0.12. The larger native-comparison harness also reproduced the failure with published Mammoth 0.8.0 on net472 and .NET 10; this is pre-existing behavior, not a newly introduced performance regression. No library source changes were made.
Prepared with OpenAI Codex.
Activity
AGiorgetti commentedon Oct 9, 2026
Implemented in draft PR #119: #119
The defect came from treating [ServiceKey] as contextual injection when the effective key was null. Constructor selection could reject a registered int parameter, while argument resolution silently supplied null for string/object parameters. Both paths now require a non-null effective key before injecting it. Without one, the existing ordinary-registration/default path applies, matching native Microsoft DI. For example, a registered int value 42 is now supplied to an unkeyed mapped constructor even when its key parameter has [ServiceKey].
Named value/key overrides still run first; genuine non-null keys retain type validation. Registered null factory results do not become optional defaults, missing required registrations fail before unrelated activation, and factory exceptions pass through unchanged. The fix adds no reflection and updates the README and vNext changelog.
Tests were committed first. The 240 new cases per target produced 138 failures and 102 passing controls before the fix on all four targets. Four older cases asserted the defect's null-injection behavior; corrected native expectations also failed before the fix, with their two keyed controls passing.
Verification for final commit
e8fc2422cf02dffb60361d12df848361bbb484df:Paused for review. This is the last bug in the original #106–#112 list; after review and merge, a release-validation checkpoint is appropriate. No release actions were taken.
test: reproduce unkeyed ServiceKey activation (#112)
fix: resolve null-context ServiceKey parameters as ordinary dependenc…