Keyed Services
Register several implementations of one interface under keys, inject a fixed one with [FromKeyedServices] or pick one at run time.
builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");
app.MapPost("/refunds", ([FromKeyedServices("stripe")] IPaymentGateway gateway) => ...);Keyed services (since .NET 8) let you register more than one implementation of the same service type and ask for a specific one by key. The key is any object compared with Equals: a string, an enum value or a constant. Typical use: two payment gateways behind IPaymentGateway, two blob stores, or a primary and a reporting database connection.
Registering and resolving
AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe"), plusAddKeyedScopedandAddKeyedTransient, with the same lifetime rules as unkeyed services.[FromKeyedServices("stripe")]on a constructor parameter or minimal API parameter fixes the key at compile time.GetRequiredKeyedService<IPaymentGateway>(key)picks at run time;GetKeyedServicereturns null for an unknown key.[ServiceKey]on a constructor parameter injects the key the instance was resolved with, so one class can serve several keys.KeyedService.AnyKeyregisters a fallback that answers any key with no specific registration.
Keyed and unkeyed are separate worlds
A keyed registration does not satisfy an unkeyed request. GetService<IPaymentGateway>() and IEnumerable<IPaymentGateway> do not see the keyed entries; resolving the unkeyed type throws if you only registered keyed ones. If most code wants "the default gateway", add an unkeyed registration that forwards to a key: AddSingleton(sp => sp.GetRequiredKeyedService<IPaymentGateway>("stripe")). Forwarding also keeps one instance instead of two.
Choosing between providers at run time
When the key comes from data (the order's payment method, the tenant's configured provider), resolve by key inside one small resolver class rather than scattering keyed lookups through handlers. Register the resolver as scoped so the IServiceProvider it receives is the request scope; a singleton resolver holds the root provider and breaks the day a gateway becomes scoped. Use GetKeyedService, which returns null for an unknown key: GetRequiredKeyedService with a key from a request body turns a typo into an InvalidOperationException and a 500 instead of a 400.
| Approach | Choose when |
|---|---|
[FromKeyedServices] | The consumer always needs one specific implementation |
Resolver + GetKeyedService | The implementation depends on request data |
IEnumerable<T> + a Name property | You also need to list or iterate all implementations |
Failure modes
Keys are not checked by the compiler: a typo in a string key fails at resolution (or at startup with ValidateOnBuild when used in a constructor). Use constants or an enum. Keyed lookups are also a service locator in disguise if every class takes IServiceProvider; keep the runtime lookup in one place.
Example
When to use it
- Route POST /orders/{id}/pay to the Stripe or Adyen gateway based on the payment method stored on the order
- Inject the invoices blob container into an archiver while product images use a different container under another key
- Serve EU countries from one VAT calculator registered under KeyedService.AnyKey and the US from a sales-tax implementation
More examples
Keyed dependency in a constructor
<code>[FromKeyedServices]</code> works on constructor parameters of any class the container builds, not only on endpoint parameters. Because the key is in the constructor, <code>ValidateOnBuild</code> can verify at startup that an <code>"invoices"</code> store exists, which is not true for keys looked up at run time.
using Microsoft.Extensions.Logging;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddKeyedSingleton<IBlobStore, InvoiceBlobStore>("invoices");
builder.Services.AddKeyedSingleton<IBlobStore, ProductImageStore>("product-images");
builder.Services.AddScoped<InvoiceArchiver>();
var app = builder.Build();
app.MapPost("/invoices/{number}/archive", async (string number, InvoiceArchiver archiver, CancellationToken ct) =>
{
await archiver.ArchiveAsync(number, "%PDF-1.7"u8.ToArray(), ct);
return Results.Accepted();
});
app.Run();
public interface IBlobStore
{
Task PutAsync(string path, byte[] content, CancellationToken ct);
}
public sealed class InvoiceBlobStore(ILogger<InvoiceBlobStore> logger) : IBlobStore
{
public Task PutAsync(string path, byte[] content, CancellationToken ct)
{
logger.LogInformation("Stored {Bytes} bytes at invoices/{Path}", content.Length, path);
return Task.CompletedTask;
}
}
public sealed class ProductImageStore : IBlobStore
{
public Task PutAsync(string path, byte[] content, CancellationToken ct) => Task.CompletedTask;
}
public sealed class InvoiceArchiver([FromKeyedServices("invoices")] IBlobStore store)
{
public Task ArchiveAsync(string number, byte[] pdf, CancellationToken ct) =>
store.PutAsync($"{DateTime.UtcNow:yyyy/MM}/{number}.pdf", pdf, ct);
}A fallback with KeyedService.AnyKey
An <code>AnyKey</code> registration answers every key that has no exact match, and <code>[ServiceKey]</code> tells the instance which key it was asked for, so one class can be parameterised by country. A singleton registered under <code>AnyKey</code> gets one instance per distinct key requested. Exact registrations always win over the fallback.
var services = new ServiceCollection();
// Specific registration for one region, fallback for every other key.
services.AddKeyedSingleton<ITaxCalculator>("US", (_, _) => new SalesTaxCalculator());
services.AddKeyedSingleton<ITaxCalculator, VatCalculator>(KeyedService.AnyKey);
using var provider = services.BuildServiceProvider();
foreach (var country in new[] { "US", "DE", "FR" })
{
var calculator = provider.GetRequiredKeyedService<ITaxCalculator>(country);
Console.WriteLine($"{country}: {calculator.Describe()}");
}
public interface ITaxCalculator
{
string Describe();
}
public sealed class SalesTaxCalculator : ITaxCalculator
{
public string Describe() => "state sales tax";
}
public sealed class VatCalculator([ServiceKey] object country) : ITaxCalculator
{
public string Describe() => $"VAT rules for {country}";
}Enum keys and a test that every key resolves
Enum keys remove string typos, and a test that loops over <code>Enum.GetValues</code> fails as soon as someone adds <code>PaymentMethod.Wallet</code> without registering a gateway. Note that the key is compared by value and type: the boxed enum <code>PaymentMethod.Card</code> does not match the string <code>"Card"</code>.
using Xunit;
public enum PaymentMethod { Card, BankTransfer, Invoice }
public interface IPaymentGateway
{
string Charge(decimal amount);
}
public sealed class CardGateway : IPaymentGateway { public string Charge(decimal a) => $"card {a}"; }
public sealed class BankTransferGateway : IPaymentGateway { public string Charge(decimal a) => $"sepa {a}"; }
public sealed class InvoiceGateway : IPaymentGateway { public string Charge(decimal a) => $"invoice {a}"; }
public static class PaymentsRegistration
{
public static IServiceCollection AddPayments(this IServiceCollection services)
{
services.AddKeyedScoped<IPaymentGateway, CardGateway>(PaymentMethod.Card);
services.AddKeyedScoped<IPaymentGateway, BankTransferGateway>(PaymentMethod.BankTransfer);
services.AddKeyedScoped<IPaymentGateway, InvoiceGateway>(PaymentMethod.Invoice);
return services;
}
}
public class PaymentRegistrationTests
{
[Fact]
public void Every_payment_method_has_a_gateway()
{
using var provider = new ServiceCollection().AddPayments().BuildServiceProvider();
using var scope = provider.CreateScope();
foreach (var method in Enum.GetValues<PaymentMethod>())
{
Assert.NotNull(scope.ServiceProvider.GetKeyedService<IPaymentGateway>(method));
}
}
}
Discussion