New keyed service dependency in .NET 8

New keyed service dependency in .NET 8

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.


要查看或添加评论,请登录

Nadim Attar的更多文章

  • Task.WhenEach

    Task.WhenEach

    Are you ready to take your asynchronous programming to the next level? With the release of .NET 9, we now have – a…

  • Exploring the Exciting New Features in C# 13

    Exploring the Exciting New Features in C# 13

    C# continues to evolve, making development more efficient and expressive. With the upcoming release of C# 13, several…

  • Understanding the IGNORE_DUP_KEY Feature in SQL Server

    Understanding the IGNORE_DUP_KEY Feature in SQL Server

    When working with databases, maintaining data integrity is critical. SQL Server offers various tools to ensure this…

  • C# Discriminated Unions (Dunet)

    C# Discriminated Unions (Dunet)

    Dunet is a simple source generator for discriminated unions in C#. This is the nuget of this library: https://www.

  • HttpHandler

    HttpHandler

    After a long hiatus from posting articles, today I am presenting to you a great post discussing why we need to…

  • Types of Transactions in SQL Server

    Types of Transactions in SQL Server

    Transactions are like safety nets for databases in SQL Server. They help keep data safe and consistent by making sure…

  • Dependency Injection Lifetimes in .Net Core

    Dependency Injection Lifetimes in .Net Core

    There are three lifetimes available with the Microsoft Dependency Injection container: transient, singleton, and…

  • Implementation of Dependency Injection Pattern in C#

    Implementation of Dependency Injection Pattern in C#

    Dependency Injection (DI) is a method in software design that helps us create code that's not too closely connected…

  • SOLID Principles

    SOLID Principles

    SOLID is an acronym for five design principles — Single Responsibility Principle (SRP), Open-Closed Principle (OCP)…

    2 条评论
  • FileZilla - The free FTP solution

    FileZilla - The free FTP solution

    FileZilla is a free, open source file transfer protocol (FTP) software tool that allows users to set up FTP servers or…

社区洞察

其他会员也浏览了