Registering several implementations of one interface is easy. Injecting the correct implementation into each consumer—without service location, conditionals, or a growing factory—is the part that exposes what a dependency-injection container can actually express.
This article compares conditional registration in Simple Injector, explicit dependencies in Castle Windsor, keyed services in Autofac, constructor configuration in StructureMap, and the keyed-service support introduced in .NET 8. It is written for teams that must make the choice under enterprise constraints, including older runtimes and restricted package mirrors.
Selecting a Dependency by Consumer
Suppose we have an interface with several implementations:
public interface IDependency;
public class DependencyImplOne : IDependency;
public class DependencyImplTwo : IDependency;
Using the standard .NET Core DI container, we want to inject a particular implementation of that contract into a particular service.
In other words, several services consume different implementations of IDependency.
For example, BarService needs DependencyImplOne, while BazService needs DependencyImplTwo:
// dependency is DependencyImplOne
public class BarService(IDependency dependency) : IBarService;
// dependency is DependencyImplTwo
public class BazService(IDependency dependency) : IBazService;
Before .NET 8, the built-in container did not provide a native way to solve this problem.
It is deliberately simple and minimal so that developers can easily add functionality for their individual needs.
At the time, that policy meant using a library even to implement something as basic as the Decorator pattern.
Scrutor is useful for assembly scanning and decoration, but this registration still hides the selection rule in delegate code.
Manual Registration Without Container-Specific Features
If you stay within the built-in container, there are several ways to solve the problem. Each feels like a mixture of reinventing the wheel and applying a workaround:
- Create a factory
This introduces an additional service—say, IDependencyProvider—that is injected wherever the dependency is needed and creates the appropriate implementation based on a condition:
public class DependencyProvider : IDependencyProvider
{
public IDependency Create(string key) =>
key switch
{
"one" => new DependencyImplOne(),
"two" => new DependencyImplTwo(),
_ => throw new ArgumentOutOfRangeException(nameof(key)),
};
}
- Create a service delegate
This implements the same mechanism with a delegate instead of a class. The container registers a function rather than an instance:
public delegate IDependency DependencyCreator(string key);
// ...
services.AddSingleton<DependencyCreator>(key => ...);
- Inject
IEnumerable<IDependency>and iterate over it
This option works, but it has an even stronger code smell.
Recall that you can resolve a registered dependency in two ways:
- As a single instance, which gives you the last registration;
- As a collection, which gives you every registration.
In the second case, consuming the dependency looks roughly like this:
public class BarService : IBarService
{
public BarService(IEnumerable<IDependency> dependencies)
{
_dependency = dependencies.FirstOrDefault(
x => x.GetType() == typeof(DependencyImplOne));
}
}
- Register the consumer explicitly
During registration, manually describe how the consuming service should be instantiated:
services.AddTransient<IBazService>(_ => new BazService(new DependencyImplTwo()));
In my opinion, none of these options looks particularly good. They illustrate the available workarounds; they are not recommendations.
Everything points toward alternative tools.
Simple Injector: Conditional Registration
"Conditional registration" means that a registered implementation is injected into service consumers that satisfy a particular condition.
Simple Injector provides context-dependent injection of a specific implementation through RegisterConditional:
container.RegisterConditional<ILogger, NullLogger>(c =>
c.Consumer.ImplementationType == typeof(HomeController));
container.RegisterConditional<ILogger, FileLogger>(c =>
c.Consumer.ImplementationType == typeof(UsersController));
container.RegisterConditional<ILogger, DatabaseLogger>(c => !c.Handled);
This example shows that conditional registration can select a dependency based on the consumer type.
HomeController receives NullLogger, UsersController receives FileLogger, and every other ILogger consumer receives DatabaseLogger.
Castle Windsor: Explicit Dependencies
Returning to the logger example, suppose ILogger has two implementations: a standard Logger and a secure SecureLogger that must be used by TransactionProcessingEngine.
In Castle Windsor, you can configure this with Dependency.OnComponent.
This method specifies the concrete dependency to inject.
It has many overloads, so you can express the dependency in several ways, from named dependencies to explicit types.
The simplest option looks like this:
container.Register(
Component
.For<ITransactionProcessingEngine>()
.ImplementedBy<TransactionProcessingEngine>()
.DependsOn(Dependency.OnComponent<ILogger, SecureLogger>()));
Autofac: Keyed Services
Autofac lets you inject a particular dependency by specifying a key associated with the desired implementation.
Suppose we have an IDisplay service that displays an IArtwork.
To specify that we want to inject the MyPainting implementation, we can use KeyFilterAttribute.
It filters by the specified key and selects the appropriate dependency.
For example:
var builder = new ContainerBuilder();
builder.RegisterType<MyPainting>().Keyed<IArtwork>("MyPainting");
builder.RegisterType<ArtDisplay>().As<IDisplay>().WithAttributeFiltering();
var container = builder.Build();
public class ArtDisplay(
[KeyFilter("MyPainting")] IArtwork art) : IDisplay;
StructureMap: Constructor Configuration
StructureMap solves the problem by configuring the consuming service's constructor.
The approach resembles Autofac's, but the binding uses the name of the constructor parameter in the contract consumer.
Suppose we have an IMessageService for sending messages, implemented by SmsService and EmailService. Different scenarios need different implementations. The configuration would look roughly like this:
var container = new Container(x =>
{
x.For<FooScenario>()
.Use<FooScenario>()
.Ctor<IMessageService>("messageService")
.Is<SmsService>();
x.For<BarScenario>()
.Use<BarScenario>()
.Ctor<IMessageService>("messageService")
.Is<EmailService>();
});
//sms
public class FooScenario(IMessageService messageService);
// email
public class BarScenario(IMessageService messageService);
Built-In Keyed Services in .NET 8
If you are planning an upgrade, I have good news: ASP.NET Core 8 finally added keyed services to dependency injection.
The implementation uses a key-based mechanism similar to Autofac's.
According to the [FromKeyedServices] attribute contract, the key has type object, so you can use strings, enums, and other values.
The attribute supports injection not only into service-consumer constructors but also into controller methods, extending the functionality added in .NET 7.
Returning to our original example, it now looks like this:
builder.Services.AddKeyedSingleton<IDependency, DependencyImplOne>("one");
builder.Services.AddKeyedSingleton<IDependency, DependencyImplTwo>("two");
public interface IDependency;
public class DependencyImplOne : IDependency;
public class DependencyImplTwo : IDependency;
public class BarService(
[FromKeyedServices("one")] IDependency dependency) : IBarService;
public class BazService(
[FromKeyedServices("two")] IDependency dependency) : IBazService;
For applications already targeting .NET 8 or later, built-in keyed services remove the need to adopt a third-party container for this requirement alone.
Choosing a Keyed or Conditional DI Strategy
Use keyed or conditional registration when the choice of implementation is configuration: the container knows which service a consumer requires. If the choice depends on runtime business data, a domain-level strategy or factory usually communicates that decision more clearly than asking the container to make it.
On .NET 8 or later, start with built-in keyed services. On older runtimes, choose a third-party container only after considering its registration model, diagnostics, maintenance status, and the migration cost it adds to the application.
Related .NET Architecture Guides
Follow Stepami on GitHub for open-source C#/.NET projects, compiler experiments, production-focused examples, and new releases.
Top comments (0)