Stop Injecting IEnumerable Just to Pick One Service

Learn how Keyed Dependency Injection lets you register multiple implementations of the same service and resolve the right one by key in .NET and ASP.NET Core.

Stop Injecting IEnumerable Just to Pick One Service cover

Introduction

Let’s start with a situation most of us have written at least once.

You have multiple implementations of the same interface. You inject IEnumerable<T>, then pick the one you need based on a string from the request. It is simple, obvious, and totally fine when the codebase is small.

Let’s use report generation as the running example.

The interface is straightforward:

public interface IReportFormatter { string Format { get; } string GenerateReport(); }

And there are multiple implementations behind it:

public sealed class HtmlReportFormatter : IReportFormatter { public string Format => "html"; public string GenerateReport() => "Report generated in HTML format"; } public sealed class PdfReportFormatter : IReportFormatter { public string Format => "pdf"; public string GenerateReport() => "Report generated in PDF format"; } public sealed class MarkdownReportFormatter : IReportFormatter { public string Format => "markdown"; public string GenerateReport() => "Report generated in Markdown format"; }

Again, nothing wrong so far. This is a common and practical starting point.

The problem begins when selection logic ("which implementation should I use?") starts leaking into controllers and services everywhere. That logic is usually string-based, repeated, and easy to get subtly inconsistent over time.

This is where Keyed Dependency Injection in ASP.NET Core helps. It doesn’t replace all DI usage. It solves a specific pain: picking one implementation cleanly when there are many.

The Traditional Approach

A traditional controller often looks like this:

public sealed class TraditionalController(IEnumerable<IReportFormatter> reportFormatters) : ControllerBase { [HttpGet("report")] public IActionResult Report([FromQuery] string format) { if (string.IsNullOrWhiteSpace(format)) { return BadRequest(new { error = "Format is required. Use one of: pdf, html, markdown." }); } var formatter = reportFormatters.FirstOrDefault(x => x.Format.Equals(format, StringComparison.OrdinalIgnoreCase)); if (formatter is null) { return BadRequest(new { error = $"Invalid format '{format}'. Use one of: pdf, html, markdown." }); } return Ok(new { format = formatter.Format, message = formatter.GenerateReport() }); } }

And registrations are the classic ones:

builder.Services.AddTransient<IReportFormatter, PdfReportFormatter>(); builder.Services.AddTransient<IReportFormatter, HtmlReportFormatter>(); builder.Services.AddTransient<IReportFormatter, MarkdownReportFormatter>();

This works well and it is easy to explain to any teammate. Inject all implementations, filter by a key (format), run the selected one.

For small projects, this is often enough.

The catch is not performance. The catch is code shape.

You can already see the controller doing two jobs:

  1. Handling HTTP concerns.
  2. Owning service-selection rules.

Once that pattern spreads, every place that needs one implementation starts carrying similar selection code.

Why This Gets Messy Over Time

The traditional approach is not bad. It just scales awkwardly in terms of readability and maintenance.

Another subtle issue is discoverability. When selection is done with IEnumerable filtering, you often need to read endpoint code to understand which implementation can be used and how it is chosen. With keyed registration, that map is visible in one place during startup, which makes onboarding easier for new contributors.

A few practical issues show up:

  • Manual filtering appears in multiple endpoints.
  • String comparisons are repeated (Equals(..., OrdinalIgnoreCase), trimming, validation).
  • Allowed values lists are duplicated in different places.
  • Error messages can drift and become inconsistent.

This endpoint is still clean because the scope is intentionally small. But imagine five more places needing report formatting. Or imagine a second axis of variation (region, tenant, or feature version). You now have controller logic that mostly exists to choose services.

That is the key point: business code slowly becomes routing logic between implementations.

This also affects test clarity. A test that should verify business behavior can end up mostly verifying string matching and fallback paths.

None of this is catastrophic. It is just friction that grows with each new implementation.

Enter Keyed Dependency Injection

Now look at the notification example registered with keys.

builder.Services.AddKeyedTransient<INotificationService, EmailNotificationService>("email"); builder.Services.AddKeyedTransient<INotificationService, SmsNotificationService>("sms"); builder.Services.AddKeyedTransient<INotificationService, PushNotificationService>("push");

And the contract is simple:

public interface INotificationService { string ProviderName { get; } string SendNotification(); }

Implementations are equally direct:

public sealed class EmailNotificationService : INotificationService { public string ProviderName => "email"; public string SendNotification() => "Notification sent using Email provider"; } public sealed class SmsNotificationService : INotificationService { public string ProviderName => "sms"; public string SendNotification() => "Notification sent using SMS provider"; } public sealed class PushNotificationService : INotificationService { public string ProviderName => "push"; public string SendNotification() => "Notification sent using Push provider"; }

What keyed registration gives you is explicit mapping at composition time. You define once that "email" means EmailNotificationService, and you stop rewriting that decision in endpoint code.

It shifts selection from ad-hoc runtime filtering to a clear DI configuration.

Resolving Services with FromKeyedServices

For fixed routes, FromKeyedServices gives a very clean controller.

public sealed class KeyedController( [FromKeyedServices("email")] INotificationService emailNotificationService, [FromKeyedServices("sms")] INotificationService smsNotificationService, [FromKeyedServices("push")] INotificationService pushNotificationService) : ControllerBase { [HttpGet("email")] public IActionResult Email() => Ok(new { provider = "email", message = emailNotificationService.SendNotification() }); [HttpGet("sms")] public IActionResult Sms() => Ok(new { provider = "sms", message = smsNotificationService.SendNotification() }); [HttpGet("push")] public IActionResult Push() => Ok(new { provider = "push", message = pushNotificationService.SendNotification() }); }

This style reads almost like plain intent:

  • This endpoint is for email.
  • Inject the email implementation.
  • Execute it.

No filtering. No query-to-service mapping code. No controller-level selection branch.

That makes the controller easier to scan, and the implementation detail of service selection stays where it belongs: DI registration.

There is also less room for accidental mismatch. You are not manually checking if provider == "email" in ten different places.

If your endpoint-to-provider mapping is static, this is usually the cleanest path.

Runtime Resolution

Sometimes the provider is dynamic. You only know it at runtime from user input, tenant config, or feature flag state.

A runtime-resolution version can look like this:

public sealed class DynamicKeyedController(IServiceProvider serviceProvider) : ControllerBase { private static readonly HashSet<string> ValidProviders = ["email", "sms", "push"]; [HttpGet("send")] public IActionResult Send([FromQuery] string provider) { if (string.IsNullOrWhiteSpace(provider)) { return BadRequest(new { error = "Provider is required. Use one of: email, sms, push." }); } var normalizedProvider = provider.Trim().ToLowerInvariant(); if (!ValidProviders.Contains(normalizedProvider)) { return BadRequest(new { error = $"Invalid provider '{provider}'. Use one of: email, sms, push." }); } var service = serviceProvider.GetRequiredKeyedService<INotificationService>(normalizedProvider); return Ok(new { provider = normalizedProvider, message = service.SendNotification() }); } }

The important line is this one:

var service = serviceProvider.GetRequiredKeyedService<INotificationService>(normalizedProvider);

This is the runtime equivalent of constructor-based keyed injection. You still use keyed registrations, but you resolve using a value decided during request handling.

When does this make sense?

  • The user picks the provider in the UI.
  • A tenant setting chooses the provider.
  • A feature flag swaps providers progressively.

Compared to IEnumerable filtering, this keeps resolution semantics in the DI container and avoids scanning all implementations manually.

One thing this controller does right is input validation before resolution. It normalizes input and checks it against a known set. That gives predictable errors instead of vague failures.

Real-World Scenarios

Notification providers are a natural fit, but the same pattern appears in other areas:

  • Payment gateways (Stripe, PayPal, Adyen)
  • Storage providers (local, S3, Azure Blob)
  • Export engines (PDF, XLSX, CSV)
  • Messaging backends (RabbitMQ, Kafka, Azure Service Bus)

The pattern is always the same: one contract, multiple implementations, and a need to select one implementation cleanly.

If the choice is fixed by route or feature, use FromKeyedServices. If the choice is runtime-driven, use GetRequiredKeyedService with controlled keys.

A Few Things to Keep in Mind

Keyed DI is useful, but it stays maintainable only if you keep a few practical guardrails in place. The first is key management. In this example the keys are string literals ("email", "sms", "push") because that keeps the snippet short, but in real code it is better to centralize keys as constants so typos do not silently create bugs.

Validation should also happen at the boundary. The runtime example validates and normalizes provider before calling GetRequiredKeyedService, which is exactly what you want when user input drives resolution. That pattern keeps failures predictable and gives cleaner error responses.

It is also worth being selective about where you use keyed DI. If there is only one implementation, normal constructor injection is simpler. Keyed DI helps when you genuinely need to choose among multiple registered implementations.

Finally, keep the role of keyed DI clear in your head: it solves service selection, not overall architecture. It does not replace lifecycle decisions or object creation strategy. And while IServiceProvider is fine for truly dynamic cases, fixed dependencies are usually cleaner with constructor injection and FromKeyedServices.

Conclusion

The progression is straightforward when you compare both styles side by side.

The traditional IEnumerable approach is fine and often a good first step. But as soon as multiple endpoints start repeating selection logic, business code gets noisier than it needs to be.

Keyed DI gives you a cleaner split of responsibilities:

  • Registration defines mapping.
  • Controllers focus on request handling and business behavior.
  • Runtime selection still stays explicit and controlled.

The result is not magical. It is simply less repetitive code, fewer manual selection branches, and easier maintenance as implementations grow.

One practical way to adopt this is incremental. You do not need a rewrite. Start with one area where service selection is repeated, move registrations to keyed DI, and keep behavior identical. Once the team sees the code get simpler, the pattern usually spreads naturally to other multi-implementation hotspots.

If you keep finding FirstOrDefault on IEnumerable<T> just to pick one service by name, that is usually your signal to move the selection into keyed registrations.

Access the Code on GitHub

If you want to run this example locally or browse the full code, you can find it here:

https://github.com/YogeshHadiya33/KeyedDependencyInjection