.NET & C# Development · Lesson 8 of 92

Keyed Services — Register Multiple Implementations Cleanly

The Problem — Multiple Implementations of One Interface

Your app sends notifications via email, SMS, and push. All three implement the same interface.

C#
public interface INotificationService
{
    Task SendAsync(string recipient, string message, CancellationToken ct = default);
}

public class EmailNotificationService : INotificationService { ... }
public class SmsNotificationService : INotificationService { ... }
public class PushNotificationService : INotificationService { ... }

If you register all three the standard way, injecting INotificationService gives you only the last registration:

C#
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<INotificationService, SmsNotificationService>();
builder.Services.AddScoped<INotificationService, PushNotificationService>();

// Injecting INotificationService resolves PushNotificationService (last wins)
// Injecting IEnumerable<INotificationService> gives you all three — but which is which?

The Old Workaround — Factory Pattern

Before .NET 8, the cleanest approach was a factory:

C#
public enum NotificationChannel { Email, Sms, Push }

public interface INotificationServiceFactory
{
    INotificationService GetService(NotificationChannel channel);
}

public class NotificationServiceFactory : INotificationServiceFactory
{
    private readonly IServiceProvider _sp;

    public NotificationServiceFactory(IServiceProvider sp) => _sp = sp;

    public INotificationService GetService(NotificationChannel channel) => channel switch
    {
        NotificationChannel.Email => _sp.GetRequiredService<EmailNotificationService>(),
        NotificationChannel.Sms   => _sp.GetRequiredService<SmsNotificationService>(),
        NotificationChannel.Push  => _sp.GetRequiredService<PushNotificationService>(),
        _ => throw new ArgumentOutOfRangeException(nameof(channel))
    };
}

// Registration (register concrete types, not interface)
builder.Services.AddScoped<EmailNotificationService>();
builder.Services.AddScoped<SmsNotificationService>();
builder.Services.AddScoped<PushNotificationService>();
builder.Services.AddScoped<INotificationServiceFactory, NotificationServiceFactory>();

This works but it's boilerplate — and the factory becomes a service locator. .NET 8 solves this natively.


.NET 8 Keyed Services

Register services with a string key:

C#
// Program.cs
builder.Services.AddKeyedScoped<INotificationService, EmailNotificationService>("email");
builder.Services.AddKeyedScoped<INotificationService, SmsNotificationService>("sms");
builder.Services.AddKeyedScoped<INotificationService, PushNotificationService>("push");

Keys can be any object — strings, enums, integers:

C#
// Using an enum as the key (more type-safe)
public enum NotificationChannel { Email, Sms, Push }

builder.Services.AddKeyedScoped<INotificationService, EmailNotificationService>(NotificationChannel.Email);
builder.Services.AddKeyedScoped<INotificationService, SmsNotificationService>(NotificationChannel.Sms);
builder.Services.AddKeyedScoped<INotificationService, PushNotificationService>(NotificationChannel.Push);

Resolving With [FromKeyedServices]

In controllers and services, use the [FromKeyedServices] attribute:

C#
[ApiController]
[Route("api/notifications")]
public class NotificationsController : ControllerBase
{
    private readonly INotificationService _emailSvc;
    private readonly INotificationService _smsSvc;

    public NotificationsController(
        [FromKeyedServices(NotificationChannel.Email)] INotificationService email,
        [FromKeyedServices(NotificationChannel.Sms)]   INotificationService sms)
    {
        _emailSvc = email;
        _smsSvc   = sms;
    }

    [HttpPost("email")]
    public async Task<IActionResult> SendEmail([FromBody] NotificationRequest req, CancellationToken ct)
    {
        await _emailSvc.SendAsync(req.Recipient, req.Message, ct);
        return NoContent();
    }

    [HttpPost("sms")]
    public async Task<IActionResult> SendSms([FromBody] NotificationRequest req, CancellationToken ct)
    {
        await _smsSvc.SendAsync(req.Recipient, req.Message, ct);
        return NoContent();
    }
}

Resolving Dynamically With IKeyedServiceProvider

When you need to pick the implementation at runtime (e.g. based on user preference or request data):

C#
public class NotificationDispatcher
{
    private readonly IKeyedServiceProvider _keyedSp;

    public NotificationDispatcher(IKeyedServiceProvider keyedSp)
        => _keyedSp = keyedSp;

    public async Task SendAsync(
        NotificationChannel channel,
        string recipient,
        string message,
        CancellationToken ct = default)
    {
        var svc = _keyedSp.GetRequiredKeyedService<INotificationService>(channel);
        await svc.SendAsync(recipient, message, ct);
    }

    public async Task SendToAllChannelsAsync(
        string recipient,
        string message,
        CancellationToken ct = default)
    {
        var channels = Enum.GetValues<NotificationChannel>();
        var tasks = channels.Select(ch =>
        {
            var svc = _keyedSp.GetRequiredKeyedService<INotificationService>(ch);
            return svc.SendAsync(recipient, message, ct);
        });
        await Task.WhenAll(tasks);
    }
}

// Register the dispatcher
builder.Services.AddScoped<NotificationDispatcher>();

Real Example — Payment Providers

A payment platform needs to route orders to Stripe, PayPal, or Klarna based on the customer's choice.

C#
// Interface
public interface IPaymentProvider
{
    string ProviderName { get; }
    Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct);
    Task<RefundResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct);
}

// Implementations
public class StripePaymentProvider : IPaymentProvider
{
    private readonly StripeOptions _options;
    private readonly ILogger<StripePaymentProvider> _logger;

    public string ProviderName => "Stripe";

    public StripePaymentProvider(
        IOptions<StripeOptions> options,
        ILogger<StripePaymentProvider> logger)
    {
        _options = options.Value;
        _logger  = logger;
    }

    public async Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct)
    {
        _logger.LogInformation("Charging {Amount} via Stripe", request.Amount);
        // Stripe SDK call
        var charge = await StripeClient.ChargeAsync(request.Token, request.Amount, ct);
        return new PaymentResult(charge.Id, PaymentStatus.Succeeded);
    }

    public async Task<RefundResult> RefundAsync(string transactionId, decimal amount, CancellationToken ct)
    {
        var refund = await StripeClient.RefundAsync(transactionId, amount, ct);
        return new RefundResult(refund.Id, RefundStatus.Succeeded);
    }
}

public class PayPalPaymentProvider : IPaymentProvider
{
    public string ProviderName => "PayPal";
    // ... PayPal SDK implementation
}

public class KlarnaPaymentProvider : IPaymentProvider
{
    public string ProviderName => "Klarna";
    // ... Klarna SDK implementation
}
C#
// Program.cs — register with string keys
builder.Services.AddKeyedScoped<IPaymentProvider, StripePaymentProvider>("stripe");
builder.Services.AddKeyedScoped<IPaymentProvider, PayPalPaymentProvider>("paypal");
builder.Services.AddKeyedScoped<IPaymentProvider, KlarnaPaymentProvider>("klarna");

// Register options for each provider
builder.Services.Configure<StripeOptions>(builder.Configuration.GetSection("Stripe"));
builder.Services.Configure<PayPalOptions>(builder.Configuration.GetSection("PayPal"));
builder.Services.Configure<KlarnaOptions>(builder.Configuration.GetSection("Klarna"));
C#
// Payment service that routes to the right provider
public class PaymentService : IPaymentService
{
    private readonly IKeyedServiceProvider _keyedSp;
    private readonly IOrderRepository _orders;
    private readonly ILogger<PaymentService> _logger;

    public PaymentService(
        IKeyedServiceProvider keyedSp,
        IOrderRepository orders,
        ILogger<PaymentService> logger)
    {
        _keyedSp = keyedSp;
        _orders  = orders;
        _logger  = logger;
    }

    public async Task<PaymentResult> ProcessAsync(ProcessPaymentCommand cmd, CancellationToken ct)
    {
        var provider = _keyedSp.GetRequiredKeyedService<IPaymentProvider>(
            cmd.PaymentProvider.ToLowerInvariant());

        _logger.LogInformation(
            "Processing payment for order {OrderId} via {Provider}",
            cmd.OrderId, provider.ProviderName);

        var result = await provider.ChargeAsync(
            new PaymentRequest(cmd.Amount, cmd.PaymentToken), ct);

        await _orders.UpdatePaymentStatusAsync(cmd.OrderId, result, ct);
        return result;
    }
}
C#
// API endpoint
[ApiController]
[Route("api/payments")]
public class PaymentsController : ControllerBase
{
    private readonly IPaymentService _payments;

    public PaymentsController(IPaymentService payments) => _payments = payments;

    [HttpPost]
    public async Task<IActionResult> Process(
        [FromBody] ProcessPaymentRequest req,
        CancellationToken ct)
    {
        // req.Provider = "stripe" | "paypal" | "klarna"
        var cmd = new ProcessPaymentCommand(req.OrderId, req.Amount, req.Provider, req.Token);
        var result = await _payments.ProcessAsync(cmd, ct);
        return Ok(result);
    }
}

Keyed Services With All Lifetime Variants

C#
// All three lifetimes are supported
builder.Services.AddKeyedTransient<IPaymentProvider, StripePaymentProvider>("stripe");
builder.Services.AddKeyedScoped<IPaymentProvider, PayPalPaymentProvider>("paypal");
builder.Services.AddKeyedSingleton<IPaymentProvider, KlarnaPaymentProvider>("klarna");

// Singleton with a pre-built instance
var stripeProvider = new StripePaymentProvider(stripeOptions, logger);
builder.Services.AddKeyedSingleton<IPaymentProvider>("stripe", stripeProvider);

// Factory registration
builder.Services.AddKeyedScoped<IPaymentProvider>("paypal", (sp, key) =>
{
    var options = sp.GetRequiredService<IOptions<PayPalOptions>>();
    var logger  = sp.GetRequiredService<ILogger<PayPalPaymentProvider>>();
    return new PayPalPaymentProvider(options, logger);
});

What to Learn Next

  • Scrutor: Auto-register all services in an assembly without writing each line manually
  • Middleware Pipeline: Add cross-cutting behavior without touching your services
  • Service Lifetimes: Review the captive dependency bug before choosing lifetimes for keyed services