๐ Introduction
Since the beginning, AspNetCore provided a simple but effective dependency injection container by default, without the need of configuring it manually.
This amazing built-in implementation has worked like a charm in most of the common scenarios. However, not everything was covered as easy as pie by default.
As a counterexample, if we needed to deal with more than one implementation pointing to the same interface, we needed some kind of workaround to overcome this challenge.
โช Previous context
Having said that, we would be able to use a Factory method; or a custom implementation resolver among others (or directly using a different DI container provider like Autofac), but the one that I used the most until now was using a custom delegate and a simple enum with the different implementations.
With this aim in mind, this is how it looked an implementation having this kind of Service Resolver:
1. Services definition
Let's imagine that we have a couple of implementations fulfilling the same contract definition:
public interface IService
{
public Task<string> DoSomethingAsync();
}
public class ServiceImplementationA : IService
{
public Task<string> DoSomethingAsync()
=> Task.FromResult("Hello there from Service A");
}
public class ServiceImplementationB : IService
{
public Task<string> DoSomethingAsync()
=> Task.FromResult("Hello there from Service B");
}
2. Custom delegate and Enum
With this scenario, we will need something in charge of resolving this within the needed part of the project (Controller, Service...) and this will have the shape of a custom delegate, considering the members of an enum that has the different implementations defined in the project:
public enum ServiceImplementation
{
A,
B
}
public delegate IService ServiceResolver(ServiceImplementation implementationType);
3. DI Container configuration
Once we have this delegate defining what we need for resolving our different implementations, let's see how to properly set up everything within the DI container:
builder.Services.AddTransient<ServiceImplementationA>();
builder.Services.AddTransient<ServiceImplementationB>();
builder.Services.AddTransient<ServiceResolver>(serviceProvider => implType =>
{
return implType switch
{
ServiceImplementation.A => serviceProvider.GetService<ServiceImplementationA>()!,
ServiceImplementation.B => serviceProvider.GetService<ServiceImplementationB>()!,
_ => throw new ArgumentException("Invalid service type")
};
});
โ ๏ธ Note that we are using a pattern matching implementation for resolving the different types, however this could also be done with a traditional switch case:
builder.Services.AddTransient<ServiceResolver>(serviceProvider => implType =>
{
switch (implType)
{
case ServiceImplementation.A:
return serviceProvider.GetService<ServiceImplementationA>()!;
case ServiceImplementation.B:
return serviceProvider.GetService<ServiceImplementationB>()!;
default:
throw new ArgumentException("Invalid service type");
}
});
4. Resolving it
Having this, in a minimal API as a quick example, this would look like the example below once we needed to resolve our different services:
app.MapGet("/test-a", async (ServiceResolver serviceResolver) =>
{
var service = serviceResolver(ServiceImplementation.A);
var data = await service.DoSomethingAsync();
return Results.Ok(data);
})
.WithName("TestEndpoint-A")
.WithOpenApi();
app.MapGet("/test-b", async (ServiceResolver serviceResolver) =>
{
var service = serviceResolver(ServiceImplementation.B);
var data = await service.DoSomethingAsync();
return Results.Ok(data);
})
.WithName("TestEndpoint-B")
.WithOpenApi();
โ ๏ธ Note that we directly inject the ServiceResolver delegate, and we request the concrete service through our initially defined enum type.
5. Running it
With the API running, once we try to execute it, this is what we have back in both endpoints:
๐ก The invocation shown in the screenshot above was using HttpRepl dotnet tool, more info here.
โฉ What's new in .NET8?
Accordingly to one of the latest tweets from David Fowler, .NET 8 will introduce a new feature called Keyed Services that will allow to define keyed elements within the DI Container. This feature will be available to any kind of AspNetCore application, and the key can be anything.
This feature isn't delivered yet in any of the current .NET 8 preview releases, so it may vary on final release.
So, the example shown above, would look like this:
1. DI Container configuration
Configuring it is quite straightforward, it's required to use the concrete extension method according to the life scope that we want to specify for the given implementation, so here we have some of the "equivalent" methods to the one that we already know.
Regular | Keyed |
---|---|
AddTransient<TService, TImplementation>() |
AddKeyedTransient<TService, TImplementation>(object key) |
AddScoped<TService, TImplementation>() |
AddKeyedScoped<TService, TImplementation>(object key) |
AddSingleton<TService, TImplementation>() |
AddKeyedSingleton<TService, TImplementation>(object key) |
Following the previous example, equally registering ours as Transient ones:
builder.Services.AddKeyedTransient<IService, ServiceImplementationA>(ServiceImplementation.A);
builder.Services.AddKeyedTransient<IService, ServiceImplementationB>(ServiceImplementation.B);
2. Resolving it
Here we have a couple of simple mechanisms for helping us to inject the concrete type. One is using the decorator [FromKeyedServices]
: very useful especially in Minimal API's. The other one is using a concrete new service provider type called IKeyedServiceProvider
:
Using [FromKeyedServices]
:
app.MapGet("/test-a", async ([FromKeyedServices(ServiceImplementation.A)] IService serviceImplA) =>
{
var data = await serviceImplA.DoSomethingAsync();
return Results.Ok(data);
})
.WithName("TestEndpoint-A")
.WithOpenApi();
app.MapGet("/test-b", async ([FromKeyedServices(ServiceImplementation.B)] IService serviceImplB) =>
{
var data = await serviceImplB.DoSomethingAsync();
return Results.Ok(data);
})
.WithName("TestEndpoint-B")
.WithOpenApi();
Using IKeyedServiceProvider
:
public class AnotherServiceConsumingA
{
private readonly IService _service;
public AnotherServiceConsumingA(IKeyedServiceProvider keyedServiceProvider)
{
_service = keyedServiceProvider.GetRequiredKeyedService<IService>(ServiceImplementation.A);
}
}
๐ Conclusion
This new feature is a simple but effective built-in way for resolving these particular situations in our code base easily without the need of using external libraries or workarounds, and the two flavours provided for resolving it in the consumer classes provides all that we need, so looking forward to the final implementation delivered in the upcoming .NET8 previews.
โ ๏ธ As commented, this is based on a preliminar information still not available on current .NET8 previews, so the final approach could vary significantly from this post.
If you liked it, please give me a โญ and follow me ๐.
๐ Resources
You can check it out the current approach code here
Top comments (4)
Nice article @xelit3
Thanks for sharing :-)
Thank you @joseluiseiguren ! Glad to see that is useful for others, thanks for your comment!
Thanks for pointing out this!
You are welcome! Thanks for reading it ๐