Use keyed services in ASP.NET Core
A few years ago I wrote an article describing how ASP.NET Core DI container behaves when multiple implementations of an interface are registered. It's time to revisit the concept because .NET 8 has something better to offer -- Keyed Services. I am going to use the same example that I used in the earlier article. We will modify the example for .NET 8.
Most commonly you register a single implementation of an interface as a service type. However, at times you may want to register multiple implementations of an interface as service types.
Let's first understand what we are trying to accomplish.
Suppose you have an interface as shown below:
public interface IHelloService
{
string SayHello();
}
The IHelloService interface contains just a single method named SayHello().
Let's further assume that you have three implementations of IHelloService with you as outlined below:
public class HelloA:IHelloService
{
public string SayHello()
{
return "Hello World!";
}
}
public class HelloB : IHelloService
{
public string SayHello()
{
return "Hello Galaxy!";
}
}
public class HelloC : IHelloService
{
public string SayHello()
{
return "Hello Universe!";
}
}
The three implementations namely HelloA, HelloB, and HelloC simply return three different strings to the caller - Hello World!, Hello Galaxy!, and Hello Universe!.
How do you register these three concrete types with the ASP.NET Core's DI container?
One of the ways is this :
builder.Services.AddScoped<HelloA>();
builder.Services.AddScoped<HelloB>();
builder.Services.AddScoped<HelloC>();
In your Program.cs you add the above code and register the concrete types HelloA, HelloB, and HelloC with the DI container. Although all of them implement IHelloService, while registering them they are registered as independent concrete types.
To grab one or more implementations of IHelloService you can use constructor injection like this :
public HomeController(HelloA hello1, HelloB hello2,
HelloC hello3)
{
string msg1 = hello1.SayHello();
string msg2 = hello2.SayHello();
string msg3 = hello3.SayHello();
}
The above code shows a constructor of the default HomeController of MVC application. The constructor has three parameters - one for each concrete type. In this case the DI container will supply the respective types as expected and the three string variables will hold - Hello World!, Hello Galaxy!, and Hello Universe! respectively.
VS Code debugger will show them as follows :
So far so good.
Now, change the way you registered the services in the Program.cs
builder.Services.AddScoped<IHelloService, HelloA>();
builder.Services.AddScoped<IHelloService, HelloB>();
builder.Services.AddScoped<IHelloService, HelloC>();
Note that AddScoped() now registers multiple implementations of IHelloService. To accommodate this change you also modify the constructor as shown below:
public HomeController(IHelloService hello1,
IHelloService hello2,
IHelloService hello3)
{
string msg1 = hello1.SayHello();
string msg2 = hello2.SayHello();
string msg3 = hello3.SayHello();
}
What's the outcome?
You will find that all the string variables contain Hello Universe! This is because DI container injects only HelloC objects in the constructor since it's the last implementation of IHelloService registered in the Program.cs.
VS Code debugger confirms this behavior.
Before .NET 8 there was no easy way to handle the situation. At the most you could do something like this :
public HomeController(IEnumerable<IHelloService> hello)
{
foreach(var obj in hello)
{
string msg = obj.SayHello();
}
}
As you can see, the constructor now takes an IEnumerable<IHelloService>. This way all the registered implementations of IHelloService are injected into the constructor. Inside, you can loop through the supplied implementations and invoke SayHello() on each. This time you will find that SayHello() correctly returns Hello World!, Hello Galaxy!, and Hello Universe! during the corresponding iterations. You can also check the type of obj to decide whether to use that implementation of IHelloService or not.
Now that you know the problem, let's see how .NET 8 attempts to fix it.
.NET 8 introduced what are known as Keyed Services. As the name suggests a keyed service is a service that is registered with the DI container along with a string key. And in order to inject an object of that service you need to specify its key.
Let's see how this is done by modifying our example.
In order to register a keyed service you will use this code in Program.cs :
builder.Services.AddKeyedScoped
<IHelloService, HelloA>("HelloA_Key");
builder.Services.AddKeyedScoped
<IHelloService, HelloB>("HelloB_Key");
builder.Services.AddKeyedScoped
<IHelloService, HelloC>("HelloC_Key");
Notice that the code now uses AddKeyedScoped() method instead of AddScoped() method used earlier. The AddKeyedScoped() method takes the interface, implementation and a string key. In this example HelloA_Key, HelloB_Key and HelloC_Key are the keys used to register HelloA, HelloB, and HelloC respectively. Just like AddKeyedScoped() method, there are other methods such as AddKeyedSingleton() and AddKeyedTransient() that can be used for singleton and transient service registrations respectively.
To inject an object into the constructor you need to specify its key using the [FromKeyedServices] attribute. The following code shows the modified constructor of HomeController :
public HomeController(
[FromKeyedServices("HelloA_Key")]IHelloService hello1,
[FromKeyedServices("HelloB_Key")]IHelloService hello2,
[FromKeyedServices("HelloC_Key")]IHelloService hello3)
{
string msg1 = hello1.SayHello();
string msg2 = hello2.SayHello();
string msg3 = hello3.SayHello();
}
As you can see, the constructor parameters are now decorated with [FromKeyedServices] attribute and each specifies the corresponding key.
If you test the msg1, msg2, and msg3 values you will see the correct messages -- Hello World!, Hello Galaxy!, and Hello Universe!.
You can also use the [FromKeyedServices] attribute in minimal APIs. The following code shows how that can be done in a MapGet() call.
app.MapGet("/hello",
([FromKeyedServices("HelloA_Key")]IHelloService hello1,
[FromKeyedServices("HelloB_Key")]IHelloService hello2,
[FromKeyedServices("HelloC_Key")]IHelloService hello3)=> {
string msg1 = hello1.SayHello();
string msg2 = hello2.SayHello();
string msg3 = hello3.SayHello();
});
If you prefer to retrieve the service inside an action you can do that as shown below:
public class HomeController : Controller
{
private IServiceProvider provider;
public HomeController(IServiceProvider provider)
{
this.provider = provider;
}
public IActionResult Index()
{
var hello1 = provider.GetRequiredKeyedService
<IHelloService>("HelloA_Key");
var hello2 = provider.GetRequiredKeyedService
<IHelloService>("HelloB_Key");
var hello3 = provider.GetRequiredKeyedService
<IHelloService>("HelloC_Key");
string msg1 = hello1.SayHello();
string msg2 = hello2.SayHello();
string msg3 = hello3.SayHello();
return View();
}
}
This code injects IServiceProvider in the constructor. Then inside the Index() action it uses the GetRequiredKeyedService() method to retrieve a service with a specified key.
There is also a variant of this method called GetKeyedService(). The difference between GetRequiredKeyedService() and GetKeyedService() is that the former throws an exception if the specified key is not found whereas the latter returns null in case the key is not found.
Instead of injecting IServiceProvider in the constructor you could have also used RequestServices property of the HttpContext as shown below:
public IActionResult Index()
{
var hello1 = HttpContext.RequestServices
.GetRequiredKeyedService
<IHelloService>("HelloA_Key");
var hello2 = HttpContext.RequestServices
.GetRequiredKeyedService
<IHelloService>("HelloB_Key");
var hello3 = HttpContext.RequestServices
.GetRequiredKeyedService
<IHelloService>("HelloC_Key");
string msg1 = hello1.SayHello();
string msg2 = hello2.SayHello();
string msg3 = hello3.SayHello();
return View();
}
You may read more about Keyed Services in the official documentation here.
That's it for now! Keep coding!!