New keyed service dependency in .NET 8
Nadim Attar
Expert Web Developer | Asp.net Core | React.js | Xamarin | Delivering Innovative Solutions @ First Screen
What’s a keyed service ?
The "keyed" registration approach involves registering dependencies using both their type and an additional key. Here is an example that shows how keyed services work in practice.
public interface IProduct {}
public class ProductA: IProduct {}
public class ProductB: IProduct {}
container.Register<IProduct , ProductA>("keyA");
container.Register<IProduct , ProductB>("keyB");
// You need to use a key to get a correct implementation
var myProductA = container.Resolve<IProduct>("keyA");
var myProductB = container.Resolve<IProduct>("keyB");
In this article, I'd focus on the advantages, disadvantages, and hidden effects of the new DI pattern.
Use cases for keyed service
Let me give you some scenarios where keyed dependency injection may come in handy.
A/B Testing or Feature Toggles
A keyed service can handle feature toggles or A/B testing, delivering unique feature sets to different users or user groups.
In the following example, I implemented a simple random generator for A/B testing. There are two implementations, BehaviorA and BehaviorB. I want to use BehaviorA for 50 percent of controller calls and BehaviorB for the other 50 percent.
// the startup class:
builder.Services.AddKeyedTransient<IBehavior, BehaviorA>(0);
builder.Services.AddKeyedTransient<IBehavior, BehaviorB>(1);
builder.Services.AddTransient<IBehavior>(serviceProvider =>
{
var number = new Random().Next(2);
return serviceProvider.GetRequiredKeyedService<IBehavior>(number);
});
[ApiController]
[Route("[controller]")]
public class ABTestingController : ControllerBase
{
private readonly IBehavior _behavior;
public ABTestingController(IBehavior behavior)
{
_behavior = behavior;
}
[HttpGet]
public string DoSomething()
{
return _behavior.DoSomething();
}
}
public interface IBehavior
{
string DoSomething();
}
public class BehaviorA : IBehavior
{
public string DoSomething()
{
return "A";
}
}
public class BehaviorB : IBehavior
{
public string DoSomething()
{
return "B";
}
}
The key point is that I registered IBehavior three times: twice as a keyed service with keys "0" and "1", and a third time as a standard transient registration using an implementation factory. This one is also used in the ABTestingController.
It’s handy because:
1- Dynamic values are not allowed in attributes. We can’t use this pattern:
public ABTestingController(
[FromKeyedServices(new Random().Next(2))] IKeyedServiceProvider keyedServiceProvider)
{
...
2- Another reason is that after you finish testing, you can easily replace the factory:
builder.Services.AddTransient<IBehavior>(serviceProvider =>
by class:
builder.Services.AddTransient<IBehavior, BehaviorA>()
Or BehaviorB, it depends on the result of testing.
Single responsibility principle.
You may have noticed that keyed services can assist with the commonly known (and often debated) single responsibility principle.
Without keyed service, you need to write code similar to this:
public class OverloadedBehavior : IBehavior
{
public string DoSomething()
{
var number = new Random().Next(2);
return number == 0 ? "A" : "B";
}
}
Configuration Management
A keyed service can handle configurations for various app sections, modules, or environments such as staging and production. The key is used to retrieve the appropriate configuration. It's similar to A/B testing, but here you use data from environment variables.
builder.Services
.AddKeyedTransient<IEmailSender, SmtpEmailSender>("production");
builder.Services
.AddKeyedTransient<IEmailSender, FakeEmailSender>("development");
builder.Services.AddTransient<IEmailSender>(serviceProvider =>
{
var env = serviceProvider.GetRequiredService<IHostingEnvironment>();
var key = env.IsDevelopment() ? "development" : "production";
return serviceProvider.GetRequiredKeyedService<IEmailSender>(key);
});
public interface IEmailSender
{
void SendEmail();
}
public class SmtpEmailSender : IEmailSender
{
public void SendEmail()
{
/*send a regular email*/
}
}
public class FakeEmailSender : IEmailSender
{
public void SendEmail()
{
/*do nothing*/
}
}
public EnvController(IEmailSender sender)
{
_sender = sender;
}
Dealing with the lifetime
Keyed services are useful when different lifetimes are required for the same dependency. A great example is resolving the Entity Framework DbContext. In complex applications, you might need DbContext instances with varying lifetimes. Keyed services enable you to implement the following pattern:
领英推荐
Services.AddTransient<EntityContext>();
services.AddKeyedScoped<EntityContext>("scoped");
public Controller1([FromKeyedServices("scoped")] EntityContext dbContext)
{
// scoped dbContext
}
public Controller2(EntityContext dbContext)
{
// transient dbContext
}
Without the support for keyed service, you’d have to introduce a DBContextFactory with a similar method like the following:
// DbContetxFactory has to be registered as scoped
public class DbContetxFactory
{
public EntityContext CreateTransientDbContext()
{
// returns a new transient instance
return new EntityContext // omitted for clarity
}
private EntityContext? _scopedDbContext;
public EntityContext CreateScopedDbContext()
{
// omitted for clarity
return _scopedDbContext ?? (_scopedDbContext = new ...)
}
}
Entity-driven resolving
The craziest way to utilize keyed service is probably entity-driven resolving. Entity-driven resolving involves saving the key into the database table and using it for service resolving. In the following example, we have two payment processors, (Stripe and Paypal).
public class PayPalProcessor : IPaymentProcessor { /* … */ }
public class StripeProcessor : IPaymentProcessor { /* … */ }
builder.Services
.AddKeyedTransient<IPaymentProcessor, PayPalProcessor>("PayPal");
builder.Services
.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("Stripe");
[ApiController]
[Route("[controller]")]
public class PaymentController : ControllerBase
{
private readonly IKeyedServiceProvider _keyedServiceProvider;
public PaymentController(IKeyedServiceProvider keyedServiceProvider)
{
_keyedServiceProvider = keyedServiceProvider;
}
[HttpGet]
public string ProcessPayment(int orderId)
{
var order = FetchOrder(orderId);
var payment= _keyedServiceProvider
.GetRequiredKeyedService<IPaymentProcessor>(order.TypeOfPayment);
var request= order.GetPaymentRequest();
payment.Process(request);
return "Payment processed";
}
The type of payment processor is stored in the Order table in the database and retrieved using the FetchOrder method. The keys (Stripe and PayPal constants) are used during service registration. The main problem arises if the database column contains something other than Stripe or PayPal, resulting in a runtime error.
While the idea may seem a bit risky and unconventional, there is also potential for it to be an extremely flexible way of resolving services.
The downsides of using keyed services
1- Complex Configuration
Developers who are new to the project might encounter a significant learning curve because of the possible complexity of the configuration, especially in larger projects with numerous dependencies. Managing the dependency injection container turned out to be a rather challenging task. The more diverse the DI configuration methods you implement, the more complex your application will become.
2- Runtime Errors
Misconfigurations very often result in runtime errors that are considerably harder to troubleshoot. Such errors occur at runtime instead of during compilation if a key is misspelled or if a corresponding dependency for a key is not registered.
Let’s see an example. If you make a typo in registering or resolving, .NET 8 shows you the following error:
// registration:
builder.Services.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("Stripe");
// typo in a capital letter
keyedServiceProvider.GetKeyedService<INotificationService>("stripe");
// the error:
Unhandled exception. System.InvalidOperationException:
No service for type 'IPaymentProcessor' has been registered.
3- Managing keys
Firstly, any .NET object can be used as a key, which can result in various issues. I usually prefer to use either classic strings or enum values.
Using string keys without abstracting them into constants leads to the proliferation of "magic strings" throughout the code, making it difficult to maintain and increasing the likelihood of errors. While enum values may seem more attractive, they also introduce their own challenges. For instance, should you have one large enum or several smaller ones? If you opt for separate enums, where should they be located?
Moreover, this is only the ice of the iceberg. If you plan to use a lot of keys, you need to think about their validation and resolving duplicity. For example, can you guess what happens when you override registration, like in the following code:
builder.Services
.AddKeyedTransient<IPaymentProcessor, PayPalProcessor>("PayPal");
builder.Services
.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("Stripe");
// "PayPal" returns StripeProcessor.
builder.Services
.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("PayPal");
app.Services
.GetKeyedService<IPaymentProcessor>("PayPal").Process(new Request());
.NET 8 When you call this code, you get the latter service (StripeProcessor). Unfortunately, there is no validation for duplicity built-in in the current version of .NET.
4- Performance Overhead
Runtime dependency resolution can lead to performance overhead, particularly in a keyed container with many dependencies. In my recent testing, I assessed the potential performance implications of using keyed services. The code:
public class OrderProcessor : IOrderProcessor
{
public void Process()
{
}
}
public class StripeProcessor : IPaymentProcessor
{
public void Process(IRequest request)
{
}
}
public class PerfTests
{
private ServiceProvider _provider;
[GlobalSetup]
public void Setup()
{
var serviceCollection = new ServiceCollection();
serviceCollection
.AddKeyedTransient<IPaymentProcessor, StripeProcessor>("Stripe");
serviceCollection
.AddTransient<IOrderProcessor, OrderProcessor>();
_provider = serviceCollection.BuildServiceProvider();
}
[Benchmark]
public object Keyed() => _provider
.GetKeyedServices<IPaymentProcessor>("Stripe");
[Benchmark]
public object Normal() => _provider
.GetServices<StripeProcessor>();
}
The results were:
| Method | Mean | Error | StdDev |
|--------|-----------|----------|----------|
| Keyed | 101.83 ns | 1.951 ns | 1.825 ns |
| Normal | 11.15 ns | 0.264 ns | 0.247 ns |
The performance of keyed services on my machine is nine times slower than the standard resolving. On the other hand, the performance degradation is in nanoseconds, which is acceptable for most standard applications. Yet, if you are chasing milliseconds, you may need to be concerned.
In Conclusion:
Keyed services have several suitable use cases, including A/B testing and lifecycle management, but they also add extra complexity. Therefore, I encourage you to experiment with them, but proceed with caution. I maintain that the more complicated the dependency resolution, the more intricate the application will become.