Keywords: ASP.NET Core | Dependency Injection | Interface Implementation | Service Registration | Factory Pattern
Abstract: This article provides an in-depth exploration of techniques for registering and resolving multiple implementations of the same interface in ASP.NET Core's dependency injection container. Through analysis of factory patterns, delegate resolvers, and other core methods, it details how to dynamically select specific implementations based on runtime conditions while addressing complex scenarios like constructor parameter injection.
In ASP.NET Core application development, scenarios frequently arise where multiple implementations of the same interface need to be registered. Unlike traditional IoC containers such as Unity, ASP.NET Core's built-in dependency injection container does not directly provide functionality for registering services by key. This article systematically analyzes this problem and offers multiple practical solutions.
Problem Background and Core Challenges
Assume we have a base service interface IService with three different implementation classes:
public interface IService { }
public class ServiceA : IService { }
public class ServiceB : IService { }
public class ServiceC : IService { }
In traditional dependency injection containers, different implementations are typically distinguished by keys. However, in ASP.NET Core's default DI container, methods like AddTransient, AddScoped, and AddSingleton do not support key parameters. This creates the challenge of being unable to directly select the appropriate implementation based on specific conditions at runtime.
Delegate Resolver Pattern
The most elegant solution is the delegate resolver pattern. This approach encapsulates service resolution logic within a dedicated delegate type, maintaining code clarity while avoiding violations of the Dependency Inversion Principle.
First, define the resolver delegate:
public delegate IService ServiceResolver(string key);
Register services in Startup.cs or Program.cs:
services.AddTransient<ServiceA>();
services.AddTransient<ServiceB>();
services.AddTransient<ServiceC>();
services.AddTransient<ServiceResolver>(serviceProvider => key =>
{
switch (key)
{
case "A":
return serviceProvider.GetService<ServiceA>();
case "B":
return serviceProvider.GetService<ServiceB>();
case "C":
return serviceProvider.GetService<ServiceC>();
default:
throw new KeyNotFoundException();
}
});
Usage in controllers or other DI-enabled classes:
public class Consumer
{
private readonly IService _service;
public Consumer(ServiceResolver serviceAccessor)
{
_service = serviceAccessor("A");
}
public void UseService()
{
_service.DoTheThing();
}
}
Handling Constructor Parameter Injection
In practical applications, service implementation classes often require specific configuration parameters. For example:
public class ServiceA : IService
{
private string _efConnectionString;
public ServiceA(string efConnectionString)
{
_efConnectionString = efConnectionString;
}
}
public class ServiceB : IService
{
private string _mongoConnectionString;
public ServiceB(string mongoConnectionString)
{
_mongoConnectionString = mongoConnectionString;
}
}
For such cases, provide necessary parameters through factory methods during service registration:
services.AddTransient<ServiceA>(provider =>
new ServiceA(configuration.GetConnectionString("EFConnection")));
services.AddTransient<ServiceB>(provider =>
new ServiceB(configuration.GetConnectionString("MongoConnection")));
GetServices Method Alternative
Another approach uses the GetServices method to retrieve all registered implementations, then selects specific instances via LINQ queries:
services.AddSingleton<IService, ServiceA>();
services.AddSingleton<IService, ServiceB>();
services.AddSingleton<IService, ServiceC>();
// Resolution where needed
var services = serviceProvider.GetServices<IService>();
var serviceB = services.First(o => o.GetType() == typeof(ServiceB));
While simple, this method suffers from poor performance with many services and lacks explicit key mapping.
Interface Refactoring Strategy
From a design pattern perspective, create specialized interfaces for each concrete implementation class:
public interface IServiceA : IService { }
public interface IServiceB : IService { }
public interface IServiceC : IService { }
Corresponding implementation classes implement these specialized interfaces:
public class ServiceA : IServiceA { }
public class ServiceB : IServiceB { }
public class ServiceC : IServiceC { }
Service registration:
services.AddTransient<IServiceA, ServiceA>();
services.AddTransient<IServiceB, ServiceB>();
services.AddTransent<IServiceC, ServiceC>();
This approach, while increasing interface count, provides the clearest dependency relationships and adheres to the Single Responsibility Principle.
.NET 8 Keyed Services Feature
For developers using .NET 8 and later, leverage the built-in keyed services functionality:
// Service registration
builder.Services.AddKeyedSingleton<IService, ServiceA>("A");
builder.Services.AddKeyedSingleton<IService, ServiceB>("B");
builder.Services.AddKeyedSingleton<IService, ServiceC>("C");
// Usage in constructors
public class Consumer
{
public Consumer([FromKeyedServices("A")] IService service)
{
_service = service;
}
}
This represents the most official and elegant solution, recommended for priority consideration in new projects.
Performance and Design Considerations
When selecting a specific approach, consider these factors:
- Performance: Delegate resolver pattern involves only one delegate call at runtime, offering optimal performance
- Maintainability: Interface refactoring strategy provides the clearest code structure
- Compatibility: Delegate resolver is compatible with all ASP.NET Core versions
- Extensibility: All solutions support dynamic addition of new implementations
Practical Application Scenarios
The multiple implementation registration pattern proves particularly useful in these scenarios:
- Data Access Layers: Supporting multiple database types (SQL, NoSQL, etc.)
- Payment Gateways: Integrating multiple payment providers
- Authentication Mechanisms: Supporting various authentication methods
- File Storage: Supporting local storage, cloud storage, and other solutions
By appropriately applying these technical solutions, developers can flexibly handle multiple implementations of the same interface in ASP.NET Core, building more robust and extensible application architectures.