Skip to Content
Course content

240: Named and Keyed Services

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this happen a dozen times: a developer needs to support multiple ways of doing the same thing. Maybe it's three different payment gateways, or two different storage providers. They register both implementations in the DI container, and then they're baffled when the application only ever uses one of them.

Take a look at this snippet. We have a notification system that should support both Email and SMS, depending on the user's preference.

public interface INotificationService {
    void Send(string message);
}

public class EmailService : INotificationService {
    public void Send(string message) => Console.WriteLine($"Email: {message}");
}

public class SmsService : INotificationService {
    public void Send(string message) => Console.WriteLine($"SMS: {message}");
}

// In Program.cs
builder.Services.AddTransient<INotificationService, EmailService>();
builder.Services.AddTransient<INotificationService, SmsService>();

// In the Controller
public class NotificationController(INotificationService notificationService) : ControllerBase {
    [HttpPost]
    public IActionResult Notify(string msg) {
        notificationService.Send(msg); 
        return Ok();
    }
}

The "Last One Wins" Trap

If you run this, you'll notice that no matter what, it always sends an SMS. Why? Because when you register the same interface multiple times in the built-in .NET DI container, the container doesn't keep a list of "options" for a single injection point. Instead, the last registration simply overwrites the previous ones for any request for a single instance of that type.

You might think, "I'll just inject IEnumerable<INotificationService>," but that's a clunky workaround. It forces your controller to iterate through every service and manually check which one it needs using if statements or is checks. It leaks implementation details into your business logic, which is exactly what DI is supposed to prevent.

Solving it with Keyed Services

Starting in .NET 8, we have a first-class way to handle this: Keyed Services. Instead of just registering a type, we register a type associated with a specific key (usually a string or an enum). This allows us to tell the container, "I don't just want an INotificationService; I want the one labeled 'sms'."

Here is how we fix the registration:

// In Program.cs
builder.Services.AddKeyedTransient<INotificationService, EmailService>("email");
builder.Services.AddKeyedTransient<INotificationService, SmsService>("sms");

Now, the container knows the difference. But how do we get that specific service into our controller? We use the [FromKeyedServices] attribute in the constructor.

public class NotificationController : ControllerBase {
    private readonly INotificationService _emailService;
    private readonly INotificationService _smsService;

    public NotificationController(
        [FromKeyedServices("email")] INotificationService emailService,
        [FromKeyedServices("sms")] INotificationService smsService) 
    {
        _emailService = emailService;
        _smsService = smsService;
    }

    [HttpPost]
    public IActionResult Notify(string msg, string preference) {
        if (preference == "email") _emailService.Send(msg);
        else _smsService.Send(msg);
        return Ok();
    }
}

Telling the Container Exactly Which Implementation You Want

The magic here is that [FromKeyedServices] acts as a metadata hint to the IServiceProvider. When the controller is being instantiated, the DI container looks at the attribute, finds the registration that matches both the type INotificationService and the key "email", and injects that specific instance.

I should mention that keys don't have to be strings. You can use Enums, which I highly recommend for larger projects to avoid "magic string" typos that only crash your app at runtime. If you use an Enum, the registration looks like AddKeyedTransient<IService, Service>(ServiceType.Azure).

One final tip: if you still need a "default" service (one that works when no key is specified), you can still register a non-keyed version of the service alongside your keyed ones. The container is smart enough to keep them separate.




📋 Practical Task

Build a Multi-Provider Cloud Storage Switcher

You are building a file upload system that needs to support two different storage providers: Local Disk and Azure Blob Storage. The system must decide which one to use based on a configuration setting.

Requirements:

  • Create an IStorageProvider interface with a method void SaveFile(string fileName, byte[] data).
  • Implement LocalStorageProvider and AzureStorageProvider.
  • In your Program.cs, register both providers as Keyed Services using the keys "local" and "azure".
  • Create a FileUploader class that accepts both services in its constructor using the [FromKeyedServices] attribute.
  • Implement a method in FileUploader called Upload(string fileName, byte[] data, bool useCloud) that routes the file to the Azure provider if useCloud is true, and the Local provider otherwise.

Success Criteria: Your code should compile and successfully call the correct SaveFile implementation based on the useCloud boolean without using IEnumerable<IStorageProvider>.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.