Keyed Services Beyond “Pick One Implementation”
Most tutorials on .NET keyed dependency injection tell the same story. You have one interface and several implementations – email versus SMS, Stripe versus PayPal, cache A versus cache B. You register each one with a key, inject the one you need with [FromKeyedServices], and move on. That story is true, and it is useful.
It is also incomplete.
I ran into this while wiring HTTP access to a system that is not one API at all, but several surfaces with different authentication, different response shapes, and different cross-cutting rules. The container already supported keyed services; the articles described strategy selection. The gap between those two framings turned out to be where the interesting design lived.
A familiar problem, poorly served by the usual examples
The service talks to a remote system through a shared client abstraction – in our case, IFluentHttpClient with a pipeline of IHttpFilter hooks that run before and after each request. Site selection, auth headers, response parsing: ordinary integration plumbing.
The remote system, however, does not present a single front door. There is an authentication endpoint, a session endpoint, an internal JSON API, and an external API with its own auth model. Each channel needs a different filter stack. Registering four copies of the client type by hand is tedious; injecting IEnumerable<IHttpFilter> and filtering by type in every consumer is worse – that is the old workaround keyed services were meant to replace.
What I wanted was simple to describe and awkward to build: a key that means “this channel,” and everything that belongs to that channel, filters included, comes along with it.
What most write-ups optimize for
The canonical example looks like this:
builder.Services.AddKeyedScoped<INotifier, EmailNotifier>("email");
builder.Services.AddKeyedScoped<INotifier, SmsNotifier>("sms");
public class OrderHandler([FromKeyedServices("email")] INotifier notifier) { }
One interface. One implementation per key. One injection site. The key chooses a business strategy.
That model breaks down when the key does not mean “which strategy” but “which pipeline.” For an HTTP integration layer, the question is not “PayPal or Stripe.” It is “what middleware runs on this wire?”
Naming integration channels, not business variants
We introduced an enum, call it HttpClientType, whose values name integration boundaries:
public enum HttpClientType
{
Internal,
External
}
Internal gets internal auth and internal response parsing. External gets external auth and leaves internal-only filters behind.
Using an enum here is the same advice you will find in Microsoft’s docs and in community posts: type-safe keys, refactor-friendly, no magic strings in attributes. The difference is what the enum means. These values are not “which notifier.” They are “which wire.”
Many filters, one key
This is the pattern that rarely appears in introductory posts.
For each channel, we register several IHttpFilter instances under the same key:
services.AddScopedFilter<SiteSelectionFilter>(HttpClientType.Internal);
services.AddScopedFilter<InternalAuthFilter>(HttpClientType.Internal);
services.AddSingletonFilter<InternalApiResponseFilter>(HttpClientType.Internal);
services.AddSingletonFilter<ResponseHeadersFilter>(HttpClientType.Internal);
services.AddScopedFilter<SiteSelectionFilter>(HttpClientType.External);
services.AddScopedFilter<ExternalAuthFilter>(HttpClientType.External);
Each helper registers the concrete filter once, then exposes it keyed:
private static void AddScopedFilter<T>(this IServiceCollection services, HttpClientType clientType)
where T : class, IHttpFilter
{
services.TryAddScoped<T>();
services.AddKeyedScoped<IHttpFilter>(
clientType,
(sp, _) => sp.GetRequiredService<T>());
}
The first line, TryAddScoped<T>(), registers the concrete filter type once in the container – SiteSelectionFilter as SiteSelectionFilter, InternalAuthFilter as InternalAuthFilter, and so on. That is the real object: its constructor, its lifetime, its dependencies. TryAdd matters because the same concrete filter often appears on more than one channel. SiteSelectionFilter, for example, runs on Internal and External. Without TryAdd, you would register it four times and fight duplicate-registration errors. With TryAdd, the concrete type is registered once no matter how many channels reference it.
The second line, AddKeyedScoped<IHttpFilter>(...), does not create a second filter. It adds a keyed entry for the interface IHttpFilter under clientType, and that entry is a thin factory: when the container resolves keyed IHttpFilter for this channel, it forwards to GetRequiredService<T>(). Same instance, two ways into the container – by concrete type if you ever need that, and by GetKeyedService when the HTTP factory asks for the pipeline.
So when GetKeyedService<IEnumerable<IHttpFilter>>(HttpClientType.Internal) runs, the container returns every keyed IHttpFilter registered for Internal, each one delegating to its concrete type. You get an ordered stack without registering four separate copies of SiteSelectionFilter or hard-wiring new SiteSelectionFilter() inside the keyed factory.
At resolve time, the HTTP factory pulls that stack:
private ICollection<IHttpFilter> GetHttpFilters(HttpClientType httpClientType)
{
return serviceProvider
.GetKeyedService<IEnumerable<IHttpFilter>>(httpClientType)
.ToArray();
}
Keyed DI here is composition, not selection. The key Internal does not point to one IHttpFilter. It points to a set of them, in registration order, the same aggregation idea as multiple non-keyed registrations, except each set is isolated per channel.
When the client is created, filters are attached and cached per type:
public IFluentHttpClient Create(HttpClientType clientType)
{
var client = serviceProvider.GetRequiredService<IFluentHttpClient>();
client.Filters = _filters.GetOrAdd(clientType, GetHttpFilters);
client.Timeout = options.Value.RequestTimeout;
return client;
}
Access classes inject a keyed client. They do not know which filters ran, and they should not.
HTTP Clients follow the same rule
Once filters are keyed by channel, client registration mirrors that structure. Every enum value gets a keyed transient, with the key passed into the factory delegate:
foreach (var clientType in Enum.GetValues<HttpClientType>())
{
services.AddKeyedTransient(
clientType,
(serviceProvider, key) =>
serviceProvider
.GetRequiredService<IFluentHttpClientFactory>()
.Create((HttpClientType)key));
}
The container supplies the key; the factory builds the pipeline. A new channel means a new enum member, its filter registrations, and nothing else – no scattered string literals, no duplicated AddHttpClient blocks.
Consumers stay ordinary:
public class RevisionAccess(
[FromKeyedServices(HttpClientType.Internal)] IFluentHttpClient httpClient)
{
// ...
}
The attribute looks like every tutorial example. The registration story underneath does not.
If you have used IHttpClientFactory with named clients, the mental model is close: the name selects handler configuration on a shared abstraction. Keyed services offer something similar – named pipelines – without leaving Microsoft.Extensions.DependencyInjection.
Boundaries worth keeping
No filter-selection logic in access classes. No if (channel == Internal), no services.OfType<InternalAuthFilter>().First(). The channel is fixed at registration and injection time.
No keyed ceremony for a single channel. One API, one auth model, one filter list: plain AddScoped is enough.
No swapping domain strategy for transport keys. “Which tax rule applies” stays in domain code. “Which auth header and response parser apply” belongs in infrastructure keys.
No service locator sprawl. GetKeyedService runs inside the HTTP factory when the client is built. Access classes receive a finished client, not the container.
Where it pays off, and where it does not
Worth considering when you have one client abstraction, several outbound surfaces, and each surface needs its own middleware stack – and you want adding a surface to be mostly registration work.
Probably overkill when you have one surface, when variation is business logic rather than transport plumbing, or when keyed resolution starts appearing all over the codebase (that usually means the factory should own more of the assembly).
What changed in practice
Keyed services are often introduced as a way to drop custom factories for “which implementation.” We still have a factory – IFluentHttpClientFactory, but its job shifted. It no longer chooses between implementations. It assembles them from keyed parts.
The container holds the matrix of channel → filters. The factory materializes a client. The access layer remains HTTP calls. Tests can substitute IKeyedServiceProvider and assert that a given HttpClientType receives the expected filter set without standing up the remote API.
You could build that matrix by hand with a Dictionary<HttpClientType, IReadOnlyList<IHttpFilter>>. You could wrap named HttpClient instances. Keyed DI makes the matrix a registrable, testable container concern, aligned with how .NET 8+ already treats multiple implementations of the same interface.

RSS - Posts