Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dependency injection: FromKeyedServiceAttribute falls back to last defined unkeyed service of the type -- unexpected behavior? #102654

Closed
askogvold opened this issue May 24, 2024 · 9 comments
Assignees
Labels
area-Extensions-DependencyInjection needs-further-triage Issue has been initially triaged, but needs deeper consideration or reconsideration
Milestone

Comments

@askogvold
Copy link

askogvold commented May 24, 2024

Description

I'm currently in a repo where we might need a mix of keyed and unkeyed services. For the specific case, various types of a service are configured, and injected through an IEnumerable. The keyed dependency would be used to retrieve a default one for some specific cases. We've been able to work around it, but I was surprised by the behavior, so I just want to check if this behavior is intended.

When configuring a service which has a constructor argument with the attribute [FromKeyedServices("myKey")] on an argument - if that service was not configured with the key, I will get injected the last one which was configured without a key.

This seems like a corner case, and in my mental model, I'd expect to get an exception if the argument is non-nullable, or null if it were. If the typing system makes that challenging, an option could be to add another attribute: FromRequiredKeyedService which would throw if the service is missing.

Reproduction Steps

The test called ShouldNotGetServiceIfKeyedIsNotAdded demonstrates the issue

namespace keys_repro;
using Microsoft.Extensions.DependencyInjection;

public class DemonstratingTheProblemTests
{
    [Fact]
    public void WorksIfKeyIsSpecified()
    {
        IServiceCollection services = new ServiceCollection();

        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceA"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceB"));
        services.AddKeyedTransient<IService>("myKey", (_,_) => new ServiceImplementation("KeyedService"));
        services.AddKeyedTransient<IService>("someOtherKey", (_,_) => new ServiceImplementation("AnotherKeyedService"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceC"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceD"));
        services.AddTransient<ServiceWhichAcceptsKeyedService>();

        var serviceProvider = services.BuildServiceProvider();
        var myResolvedService = serviceProvider.GetRequiredService<ServiceWhichAcceptsKeyedService>();

        Assert.Equal("KeyedService", myResolvedService.ServiceIfDefinedByKey!.Name);
    }

    [Fact]
    public void ShouldNotGetServiceIfKeyedIsNotAdded()
    {
        IServiceCollection services = new ServiceCollection();

        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceA"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceB"));
        // and no keyed service added - but continue with the rest as before
        services.AddKeyedTransient<IService>("someOtherKey", (_,_) => new ServiceImplementation("AnotherKeyedService"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceC"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceD"));

        services.AddTransient<ServiceWhichAcceptsKeyedService>();

        var serviceProvider = services.BuildServiceProvider();
        var myResolvedService = serviceProvider.GetRequiredService<ServiceWhichAcceptsKeyedService>();

        Assert.Null(myResolvedService.ServiceIfDefinedByKey); // This fails - ServiceD is resolved
    }
}

public record ServiceWhichAcceptsKeyedService([FromKeyedServices("myKey")] IService? ServiceIfDefinedByKey);

public interface IService
{
    string Name { get; }
}

public record ServiceImplementation(string Name) : IService;

Expected behavior

Test called ShouldNotGetServiceIfKeyedIsNotAdded should succeed - I would expect it not to resolve the service when I request a keyed service, and no keyed service is defined.

Actual behavior

The test called ShouldNotGetServiceIfKeyedIsNotAdded fails - I get the last configured unkeyed service of the type.

Regression?

No response

Known Workarounds

Explicitly taking a serviceProvider, and calling GetKeyedService or GetRequiredKeyedService works.

(for the types not defined in this example, see their implementation in the reproduction steps)

namespace keys_repro;

using Microsoft.Extensions.DependencyInjection;

public class WorkaroundTests
{
    [Fact]
    public void ShouldGetServiceWhenKeyedIsConfigured()
    {
        IServiceCollection services = new ServiceCollection();

        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceA"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceB"));
        services.AddKeyedTransient<IService>("myKey", (_,_) => new ServiceImplementation("KeyedService"));
        services.AddKeyedTransient<IService>("someOtherKey", (_,_) => new ServiceImplementation("AnotherKeyedService"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceC"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceD"));

        services.AddTransient<ServiceWhichAcceptsKeyServiceExplicitWorkaround>();

        var serviceProvider = services.BuildServiceProvider();
        var myResolvedService = serviceProvider.GetRequiredService<ServiceWhichAcceptsKeyServiceExplicitWorkaround>();

        Assert.Equal("KeyedService", myResolvedService.ServiceIfDefinedByKey!.Name); // This workaround works
    }

    [Fact]
    public void ShouldNotGetServiceIfKeyedIsNotAddedWithExplicitWorkaround()
    {
        IServiceCollection services = new ServiceCollection();

        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceA"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceB"));
        // and no keyed service added - but continue with the rest as before
        services.AddKeyedTransient<IService>("someOtherKey", (_,_) => new ServiceImplementation("AnotherKeyedService"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceC"));
        services.AddTransient<IService>(_ => new ServiceImplementation("ServiceD"));

        services.AddTransient<ServiceWhichAcceptsKeyServiceExplicitWorkaround>();

        var serviceProvider = services.BuildServiceProvider();
        var myResolvedService = serviceProvider.GetRequiredService<ServiceWhichAcceptsKeyServiceExplicitWorkaround>();

        Assert.Null(myResolvedService.ServiceIfDefinedByKey); // This workaround works
    }
}

public class ServiceWhichAcceptsKeyServiceExplicitWorkaround
{
    public IService? ServiceIfDefinedByKey { get; }

    public ServiceWhichAcceptsKeyServiceExplicitWorkaround(IServiceProvider serviceProvider)
    {
        ServiceIfDefinedByKey = serviceProvider.GetKeyedService<IService>("myKey");
    }
}

Configuration

<TargetFramework>net8.0</TargetFramework>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />

Other information

No response

@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label May 24, 2024
Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-system-serviceprocess
See info in area-owners.md if you want to be subscribed.

@askogvold
Copy link
Author

@dotnet/area-extensions-dependencyinjection

Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-dependencyinjection
See info in area-owners.md if you want to be subscribed.

@steveharter
Copy link
Member

cc @benjaminpetit

@steveharter steveharter self-assigned this Jun 12, 2024
@julealgon
Copy link

Surprised by this behavior. I agree it looks weird and should not be like that: FromKeyedServices should have "required + keyed" semantics by default and throw if no keyed service with that key is found.

This "fallback" behavior, if intentional, should at least be behind a bool setting in the attribute, something like:

public record ServiceWhichAcceptsKeyedService(
    [FromKeyedServices("myKey", FallbackToNonKeyed = true)] IService service);

Where FallbackToNonKeyed would be false by default.

@ericstj ericstj added needs-further-triage Issue has been initially triaged, but needs deeper consideration or reconsideration and removed untriaged New issue has not been triaged by the area owner labels Jun 18, 2024
@ericstj ericstj added this to the 9.0.0 milestone Jun 18, 2024
@CarnaViire
Copy link
Member

It definitely looks more like a bug than a feature...

This was a nasty surprise that we've hit now while implementing #89755 (HttpClientFactory support for keyed DI) -- will the fix make it to 9.0? @steveharter @benjaminpetit

Come context:

HttpClientFactory is a singleton service that configures (services.AddHttpClient(...)) and creates (factory.CreateClient(...)) HttpClient instances by name. Historically, it registers a "default" (unconfigured) HttpClient (client with an empty name) as a plain-Transient HttpClient service

// Register default client as HttpClient
services.TryAddTransient(s =>
{
return s.GetRequiredService<IHttpClientFactory>().CreateClient(string.Empty);
});

#89755 is about allowing to use [FromKeyedServices] to inject the required configured Named HttpClients directly, so you won't have to use the factory to retrieve them manually.

Current behavior unfortunately means that every Named HttpClient, that was NOT opted-in to, or even opted-OUT from, the Keyed registration -- instead of throwing upon injection (expected) will end up substituted with an unconfigured HttpClient instead (super misleading!). This would be a hell to troubleshoot. 😞

cc @dotnet/ncl @halter73 @davidfowl

@halter73
Copy link
Member

It definitely looks more like a bug than a feature...

Agreed. A parameter attributed with [FromKeyedServices("myKey")] should never get a service injected that GetKeyedService would not return.

I think we should patch .NET 8 to fix this. I cannot imagine on anyone relying on this behavior. And if anyone is relying on that behavior, we should nip that in the bud.

@paulomorgado
Copy link
Contributor

I think we should patch .NET 8 to fix this. I cannot imagine on anyone relying on this behavior. And if anyone is relying on that behavior, we should nip that in the bud.

If it's there, it's being relied on by someone.

If you are patching .NET 8, consider putting it behind an app setting.

@steveharter
Copy link
Member

This appears to be a duplicate of #102204 which was recently fixed.

@steveharter steveharter closed this as not planned Won't fix, can't repro, duplicate, stale Aug 8, 2024
@github-actions github-actions bot locked and limited conversation to collaborators Sep 8, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-Extensions-DependencyInjection needs-further-triage Issue has been initially triaged, but needs deeper consideration or reconsideration
Projects
None yet
Development

No branches or pull requests

8 participants