Search code examples
c#dependency-injection.net-8.0

Using Keyed Services to use multiple instances of the same type; how to key injected IOptions?


With the introduction of keyed services in .NET 8, we can now request specific interface implementations in a constructor:

public class MotorController
{
    private readonly IMotor _motor;
    public MotorController([FromKeyedServices("AxisX")] IMotor motor)
    {
        _motor = motor;        
    }
}

This is a cool addition to the language. One useful application would be having different instances of the SAME type injected into different classes based on the service key.

Note: I'm aware there's probably no good reason to do this in ASP.NET applications that make up the vast majority of C# projects out there. However, for the more niche applications I'm working on it would be a useful way to implement multiple instances of real word physical devices.

An obstacle to leveraging this is that there currently isn't a way to key and map the IOptions instances that are injected into classes; see this example (simplified as much as possible):

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

// Create a simple configuration (stand-in for JSON file, etc.)
var configBuilder = new ConfigurationBuilder();

configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
    { "Motors:X:AxisNumber", "1"},
    { "Motors:Y:AxisNumber", "2"},
    { "Motors:Z:AxisNumber", "3"}
});
var config = configBuilder.Build();

// Create a service collection with keyed services for different instances of the same type
var serviceCollection = new ServiceCollection();
serviceCollection.AddKeyedSingleton<Motor>("X");
serviceCollection.AddKeyedSingleton<Motor>("Y");
serviceCollection.AddKeyedSingleton<Motor>("Z");

// QUESTION: How do we map different config sections to different keyed services?
serviceCollection.Configure<MotorOptions>(config.GetSection("Motors:X"));

var serviceProvider = serviceCollection.BuildServiceProvider();

var motorX = serviceProvider.GetRequiredKeyedService<Motor>("X");
var motorY = serviceProvider.GetRequiredKeyedService<Motor>("Y");
var motorZ = serviceProvider.GetRequiredKeyedService<Motor>("Z");

Console.WriteLine("Motor X Axis: " + motorX.AxisNumber);
Console.WriteLine("Motor Y Axis: " + motorY.AxisNumber);
Console.WriteLine("Motor Z Axis: " + motorZ.AxisNumber);

public class Motor
{
    public Motor(IOptions<MotorOptions> options)
    {
        AxisNumber = options.Value.AxisNumber;
    }

    public int AxisNumber { get; }
}

public class MotorOptions
{
    public int AxisNumber { get; set; }
}

Running this example, you get the same axis number for every instance, since they're all sharing a single instance of IOptions<MotorOptions>:

Motor X Axis: 1
Motor Y Axis: 1
Motor Z Axis: 1

It's possible to make this work by manually creating and binding each instance of the options class, then passing the appropriate options instance to the constructor for each keyed service instance:

MotorOptions optionsX = new();
MotorOptions optionsY = new();
MotorOptions optionsZ = new();

config.Bind("Motors:X", optionsX);
config.Bind("Motors:Y", optionsY);
config.Bind("Motors:Z", optionsZ);

// Create a service collection with keyed services for different instances of the same type
serviceCollection.AddKeyedSingleton<Motor>("X", new Motor(Options.Create(optionsX)));
serviceCollection.AddKeyedSingleton<Motor>("Y", new Motor(Options.Create(optionsY)));
serviceCollection.AddKeyedSingleton<Motor>("Z", new Motor(Options.Create(optionsZ)));

However, this approach is pretty clunky, especially in a less trivial example where many other types are getting injected in the constructor alongside the IOptions argument.

Any ideas on a more graceful solution to this, or is it just too far outside the intended use of keyed services?


Solution

  • Options pattern has Named options support using IConfigureNamedOptions which you can combine with the ServiceKeyAttribute to get the key of the registered instance.

    Sample code:

    // register named options
    services.Configure<MyOpts>("X", ...);
    services.Configure<MyOpts>("Y", ...);
    
    services.AddKeyedTransient<MyService>("X");
    services.AddKeyedTransient<MyService>("Y");
    
    public class MyService
    {
        public MyOpts MyOpts { get; }
    
        public MyService(IOptionsFactory<MyOpts> opts, [ServiceKey] string servKey)
        {
            MyOpts = opts.Create(servKey); // use the service key to get named options
        }
    }