Using Keyed Services in .NET 8

.NET 8 is going to ship Keyed Services with Microsoft.Extensions.DependencyInjection, the default IOC container. Keyed services allow developers to register multiple implementations of an interface under different names (or, more generically, “keys”). This is useful in some scenarios. Let’s take a look.

First off, imagine you are building a web app that sends push notifications to Android and iOS devices. You’ll have to write different code to interact with Google and Apple’s push notification services. They use different authorization mechanisms and have different capabilities. Still, if you’re only going to use the features common to both, it would be convenient to have a single interface:

interface IPushNotificationService
{
    void SendPushNotification(string message);
}


class GooglePushNotificationService : IPushNotificationService
{
    public void SendPushNotification(string message)
    {
        Console.WriteLine($"Google: {message}");
    }
}

class ApplePushNotificationService : IPushNotificationService
{
    public void SendPushNotification(string message)
    {
        Console.WriteLine($"Apple: {message}");
    }
}

Both classes implement the same interface. Most of the code that sends notifications can be agnostic towards which particular platform is in play. Someone just has to get ahold of the correct implementation.

[FromKeyedService]

One way to grab a particular implementation is to use the [FromKeyedService] attribute. Placed on a constructor parameter, you can ask for the implementation you need by name:

class GoogleConsumer
{
    public IPushNotificationService PushNotificationService { get; }

    public GoogleConsumer([FromKeyedServices("google")] IPushNotificationService pushNotificationService)
    {
        PushNotificationService = pushNotificationService;
    }
}

[Fact]
public void Test()
{
    var services = new ServiceCollection();
    services.AddKeyedScoped<IPushNotificationService, GooglePushNotificationService>("google");
    services.AddKeyedScoped<IPushNotificationService, ApplePushNotificationService>("apple");
    services.AddScoped<GoogleConsumer>();

    var provider = services.BuildServiceProvider();
    var consumer = provider.GetRequiredService<GoogleConsumer>();
    Assert.IsType<GooglePushNotificationService>(consumer.PushNotificationService);
}

Here the GoogleConsumer knows it needs the "google" flavor of IPushNotificationService and declares that fact in the constructor. The framework will make sure it finds an implementation with that name and injects it.

This is a bit of a smell, though. The whole idea of “inversion of control” is that, well, control is inverted and comes from outside the class. So asking for a particular version of a generic interface strikes me as strange.

I’d probably not use keyed services for this situation. If my class needed the Google flavor, I might as well be upfront about it and use a Google-specific interface. Attributes like FromKeyedServices are not as explicit as interfaces and don’t show up in intellisense popup documentation.

I’d make an IGooglePushService interface that inherits from IPushNotificationService and inject that. That’s more explicit about what I need, and also gives me an interface to which I can attach platform-specific methods. Since it uses inheritance, I can still pass it on to other truly generic code that only needs IPushNotificationService.

Strategy Pattern

Keyed Services aren’t always a smell: one place they can shine is in strategy pattern code, where you don’t know what implementation you need until runtime.

That might look like this:

enum Platform
{
    Google,
    Apple
}

record User(string Username, Platform Platform);

class PushService
{
    private readonly IServiceProvider _serviceProvider;

    public PushService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void SendPushNotification(User user, string message)
    {
        var service = _serviceProvider.GetRequiredKeyedService<IPushNotificationService>(user.Platform);
        service.SendPushNotification(message);
    }
}

[Fact]
public void Test()
{
    var services = new ServiceCollection();
    services.AddKeyedScoped<IPushNotificationService, GooglePushNotificationService>(Platform.Google);
    services.AddKeyedScoped<IPushNotificationService, ApplePushNotificationService>(Platform.Apple);
    services.AddScoped<PushService>();

    var provider = services.BuildServiceProvider();

    var scope = provider.CreateScope();
    var pushService = scope.ServiceProvider.GetRequiredService<PushService>();

    // Currently crashes, should be fixed in preview 8 - https://github.com/dotnet/runtime/issues/90528
    pushService.SendPushNotification(new User("johndoe", Platform.Google), "Welcome to our cool app!");
  }

Here we have a class that doesn’t know until runtime what service it will need. It inspects state to know what platform the user has, and then resolves the right service.

But isn’t this a service locator pattern? My senior developer sent me a blog post once called Service Locator Considered Harmful

Well, sure, it is a service locator.

But sometimes you need code that locates services! If it makes you feel better, wrap it up in another class and call it the PushNotificationServiceFactory. People that hate Service Locators love factories.

Anyway, there are other ways to skin this cat of course. You could inject both and decide between them with a switch statement. You could inject an IEnumerable<IPushNotificationService> and search through it.

But I think the mechanism shown above is still better: it only instantiates the implementation you’re actually going to use.

Configured services pattern

Sometimes you have a common utility service that can be configured in different ways. It’s useful to name those different flavors and resolve them by name later.

Imagine you have a utility class called RestClient that can be configured for different APIs by setting common properties:

class RestClient
{
  public string BaseUrl { get; set; }
  public string AuthHeader { get; set; }
  public string ClientCertificate { get; set; }

  T Get<T>(string path)
  {
      throw new NotImplementedException();
  }

  // etc...
}

class GoogleRestClient
{
  public RestClient RestClient { get; }

  public GoogleRestClient(RestClient restClient)
  {
      RestClient = restClient;
  }

  void OptOutOfWebSurveilance() {
     RestClient.Post("/spying/opt-out"); // returns 404 Not Found
  }
}

class AppleRestClient
{
  public RestClient RestClient { get; }

  public AppleRestClient(RestClient restClient)
  {
      RestClient = restClient;
  }

  void UseFeature(App app) {
    RestClient.Post("/use-feature"); // returns 402 Payment Required
  }
}


[Fact]
public void Test()
{
  var services = new ServiceCollection();
  services.AddKeyedTransient<RestClient>("google", (sp, key) => new RestClient()
  {
      BaseUrl = "https://api.google.com/",
      AuthHeader = "Bearer 123", // TODO: get from config
  });

  services.AddKeyedTransient<RestClient>("apple", (sp, key) => new RestClient()
  {
      BaseUrl = "https://api.apple.com/",
      ClientCertificate = "TODO"
  });

  services.AddTransient<GoogleRestClient>(sp =>
      new GoogleRestClient(sp.GetRequiredKeyedService<RestClient>("google")));
  services.AddTransient<AppleRestClient>(sp =>
      new AppleRestClient(sp.GetRequiredKeyedService<RestClient>("apple")));


  var provider = services.BuildServiceProvider();

  // also busted in preview 7
  var googleClient = provider.GetRequiredService<GoogleRestClient>();
  Assert.Equal("https://api.google.com/", googleClient.RestClient.BaseUrl);
  }

Both AppleRestClient and GoogleRestClient wrap RestClient but the underlying client should be configured in different ways. The BaseUrl is different, and they use different authorization mechanisms.

It would be nice to lift that configuration up to the composition root (Startup.cs) and never have to think about it again. With Keyed services, you can declare your configured RestClients and then inject them by name into classes that need them.

Does this look familiar? This is exactly like the API for using AddHttpClient where you can name your different configurations. I suspect Microsoft had to do some gymnastics to make this work on the current IServiceProvider but the new keyed services feature in .NET 8 will simplify it greatly.

Further Reading