.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.
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:
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:
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:
// 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:
// 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:
[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):
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.
// 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
}// 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"));// 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;
}
}// 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
// 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