A key aspect of mastering ASP.NET Core is understanding how to effectively register and use multiple implementations of the same interface.
In this article, we delve into the nuances of this approach, guiding us through the process from concept to implementation.
Let’s dive in.
VIDEO: Resolve Multiple Implementations of the Same Interface in Dependency Injection.
Understanding the Concept
In our exploration of ASP.NET Core, understanding interfaces is fundamental.
An interface is a concept similar to a contract, outlining methods or properties that implementing classes must provide. In ASP.NET Core, interfaces are central to creating loosely coupled components, enhancing testability and flexibility in our applications.
We then blend this with dependency injection, a design pattern that injects dependencies of a class at runtime rather than hard-coding them. This pattern, integral to ASP.NET Core, allows us to develop scalable and easily maintainable applications by managing dependencies efficiently.
The true potential of these practices is realized when we encounter scenarios requiring multiple implementations of the same interface.
This approach enables our applications to dynamically select an appropriate implementation based on the runtime context, providing a level of abstraction and flexibility that is essential in handling diverse operational needs or adapting to varying environments.
However, opting to use the default dependency injection container offered by ASP.NET Core may reveal that the task is more challenging than it initially appears.
Creating the Interface
Before we dive into the exact techniques for dealing with our problem, let’s first set up a stage and create an interface:
public interface IFulfillTickets { string Fulfill(string requestId); }
This interface will be a part of an e-commerce system that provides three delivery options to the customers.
Our IFulfillTickets
interface defines a simple contract and defines a Fulfill
method that accepts a string parameter called requestId
and returns a string value.
Implementing the Interface with Multiple Classes
Going further, let’s create classes implementing our interface to represent our delivery options:
First, we create a BarcodeFulfillmentProcessor
class that will handle the barcode fulfillment flow:
public class BarcodeFulfillmentProcessor : IFulfillTickets { public string Fulfill(string requestId) => $"{requestId} | Fulfilling tickets using barcode delivery method"; } public class PostalFulfillmentProcessor : IFulfillTickets { public string Fulfill(string requestId) => $"{requestId} | Fulfilling tickets using postal delivery method"; } public class SmartcardFulfillmentProcessor : IFulfillTickets { public string Fulfill(string requestId) => $"{requestId} | Fulfilling tickets using smartcard delivery method"; }
All three classes implement the IFulfillTickets
interface and provide an implementation of the Fulfill
method specific to each delivery option. But for now, they will only return a string specific to each class.
Registering Interface Implementations in the ASP.NET Core Container
Once we have our classes ready we can register them using the default dependency injection container.
So, let’s go to our Program.cs
file and register them:
builder.Services.AddTransient<IFulfillTickets, BarcodeFulfillmentProcessor>(); builder.Services.AddTransient<IFulfillTickets, PostalFulfillmentProcessor>(); builder.Services.AddTransient<IFulfillTickets, SmartcardFulfillmentProcessor>();
As we can see, we register PostalFulfillmentProcessor
, BarcodeFulfillmentProcessor
, and SmartcardFulfillmentProcessor
classes as IFulfillTickets
interface implementations.
We can register our fulfillment processors as Transient
services, considering they are utilized just once in their lifecycle within a controller.
In a live production environment, these processors contain all the necessary logic to manage ticket fulfillment. This specific role means there’s typically no need to repurpose them elsewhere in our application.
However, considering their dedicated functionality and potential interactions with other parts of the system that may require shared state or dependencies, registering them as Scoped
services could also be feasible. This would ensure that they share the same lifecycle as related services during an individual request, potentially enhancing efficiency and consistency in certain scenarios.
Creating the Controllers
The last thing we need to do is consume our classes. Let’s then create a controller for each delivery option:
public class BarcodeController : ControllerBase { private readonly IFulfillTickets _processor; public BarcodeController(IFulfillTickets processor) { _processor = processor; } [HttpPost()] public string Post([FromBody] string requestId) { return _processor.Fulfill(requestId); } }
As we can see, the BarcodeController
class inherits from the ControllerBase
class. Its constructor accepts an IFulfillTickets
interface as a parameter.
It also has a Post
method that accepts a string parameter from the request body and returns the value returned from the Fulfill
method.
Now, in the same fashion, we implement the PostalController
class:
public class PostalController : ControllerBase { private readonly IFulfillTickets _processor; public PostalController(IFulfillTickets processor) { _processor = processor; } [HttpPost()] public string Post([FromBody] string requestId) { return Ok(_processor.Fulfill(requestId)); } }
And lastly, following the same pattern, we implement the SmartcardController
class:
public class SmartcardController : ControllerBase { private readonly IFulfillTickets _processor; public SmartcardController(IFulfillTickets processor) { _processor = processor; } [HttpPost()] public string Post([FromBody] string requestId) { return Ok(_processor.Fulfill(requestId)); } }
Now, if we run and test our endpoints we will find out that all three of them respond with the same message:
Fulfilling tickets using smartcard delivery method
This happens because we register the SmartcardFulfillmentProcessor
class as the last implementation of the IFulfillTickets
interface.
To clarify, registering multiple implementations of a single interface in our service container creates a list of services. Consequently, the service container ends up using the last registered interface implementation during injection.
Resolving Multiple Implementations at Runtime
Now, how can we fix this? We have a few options up our sleeve:
- Use the Factory Pattern
- Inject
ServiceResolver
into our classes - Redesign our interfaces
- Use a keyed service from .NET 8
- Use a different dependency injection package
We already have articles on the Factory Pattern and keyed services, so we will cover the remaining solutions, and in the keyed services section, we will focus on implementation rather than a detailed explanation.
Injecting ServiceResolver Into Class
The first solution we’ll examine is injecting the ServiceResolver
instance into our class and using it to resolve the implementation of our interface.
To do so, first, we have to declare a delegate:
public delegate IFulfillTickets FulfillmentProcessorResolver(string key);
Then in our Program.cs
file, we map our services manually:
builder.Services.AddTransient<PostalFulfillmentProcessor>(); builder.Services.AddTransient<BarcodeFulfillmentProcessor>(); builder.Services.AddTransient<SmartcardFulfillmentProcessor>(); builder.Services.AddTransient<FulfillmentProcessorResolver>(serviceProvider => key => { switch (key) { case "barcode": return serviceProvider.GetService<BarcodeFulfillmentProcessor>(); case "postal": return serviceProvider.GetService<PostalFulfillmentProcessor>(); case "smartcard": return serviceProvider.GetService<SmartcardFulfillmentProcessor>(); default: throw new KeyNotFoundException(); } });
Firstly, we change the way our processors are registered. Instead of registering them as the implementation of an IFulfillTickets
interface, we register them explicitly.
Then we register the FulfillmentProcessorResolver
delegate as a factory that will return one of our classes based on the provided key. We also add a default
case to catch any unsupported key and throw a KeyNotFoundException
.
Finally, we modify our controllers’ constructors to use a FulfillmentProcessorResolver
instance, like so:
public BarcodeController(FulfillmentProcessorResolver fulfillmentProcessorResolver) { _processor = fulfillmentProcessorResolver("barcode"); } public PostalController(FulfillmentProcessorResolver fulfillmentProcessorResolver) { _processor = fulfillmentProcessorResolver("postal"); } public SmartcardController(FulfillmentProcessorResolver fulfillmentProcessorResolver) { _processor = fulfillmentProcessorResolver("smartcard"); }
And finally, we use a FulfillmentProcessorResolver
instance to resolve the exact implementation of the fulfillment processor by providing the key.
However, this approach is not recommended because it’s a makeshift solution and breaks the single responsibility principle by requiring the service to independently resolve its own dependencies.
Redesigning Our Interfaces
Another solution we might consider is redesigning our interfaces.
As we can see, using multiple implementations of an interface can be difficult. Moreover, it can lead to developer confusion, break the single responsibility principle, or even undermine the whole purpose behind using dependency injection.
Instead, we can create an intermediate interface for each processor:
public interface IBarcodeFulfillmentProcessor : IFulfillTickets { } public interface IPostalFulfillmentProcessor : IFulfillTickets { } public interface ISmartcardFulfillmentProcessor : IFulfillTickets { }
Since each of those interfaces inherits from an IFulfillTickets
interface, they will provide the same set of methods.
As a result, the constructor of each controller will have to accept the appropriate interface:
public BarcodeController(IBarcodeFulfillmentProcessor processor) { _processor = processor; } public PostalController(IPostalFulfillmentProcessor processor) { _processor = processor; } public SmartcardController(ISmartcardFulfillmentProcessor processor) { _processor = processor; }
Finally, we register our new interfaces:
builder.Services.AddTransient<IBarcodeFulfillmentProcessor, BarcodeFulfillmentProcessor>(); builder.Services.AddTransient<IPostalFulfillmentProcessor, PostalFulfillmentProcessor>(); builder.Services.AddTransient<ISmartcardFulfillmentProcessor, SmartcardFulfillmentProcessor>();
This method requires us to create additional interfaces, but at the same time, it allows us to maintain the single responsibility principle in our project.Â
Using Keyed Services to Register Multiple Implementations of the Same Interface
With the release of .NET 8, we have another solution to our problem: keyed services.
In essence, this feature works in the same fashion as in other dependency injection packages. It allows the retrieval of an interface implementation based on a key.
To illustrate this, if we use keyed services we register our services as:
builder.Services.AddKeyedSingleton<IFulfillTickets, BarcodeFulfillmentProcessor>("barcode"); builder.Services.AddKeyedSingleton<IFulfillTickets, PostalFulfillmentProcessor>("postal"); builder.Services.AddKeyedSingleton<IFulfillTickets, SmartcardFulfillmentProcessor>("smartcard");
As we can see, each service is registered with the appropriate key to be used later for retrieval.
Finally, we will need to change the constructors of our controllers:
public BarcodeController([FromKeyedServices("barcode")] IFulfillTickets processor) { _processor = processor; } public PostalController([FromKeyedServices("postal")] IFulfillTickets processor) { _processor = processor; } public SmartcardController([FromKeyedServices("smartcard")] IFulfillTickets processor) { _processor = processor; }
All we have to do is add the FromKeyedServices(object key)
attribute to the parameter in our service constructor corresponding to our interface.
Using a Different Dependency Injection Package
Alternatively, we can utilize different dependency injection packages that provide the ability to register multiple implementations of an interface in our ASP.NET Core application.
In that case, exploring external dependency injection packages can be a valuable option. Popular choices like Autofac
, Unity
, or StructureMap
provide this functionality, often with additional flexibility and control.
However, it’s important to weigh this decision carefully. Integrating a new DI package can be a significant undertaking, potentially requiring substantial changes to our application’s configuration and architecture. We should consider factors like the complexity of our needs, the learning curve of a new package, and the implications on our project’s maintainability.
Opting for an external package is a strategic decision that goes beyond a mere implementation detail and should align with our long-term goals and resources.
Testing and Validation
Lastly, we must test each of our classes and interfaces.
In most scenarios, like what we’ve discussed here, nothing beyond unit testing will likely be required.
As each class should be tested in isolation as a separate unit, we can’t use unit tests to verify if our dependency injection is injecting the correct implementation. This is because unit tests by definition should not rely on other classes utilized by the tested class, but rather they should use mocked services whose behavior we can control.
Moving to integration testing, this is where we see how well the different parts of our application work together in a real-world-like environment. Because of that, we can utilize integration tests to perform verification of injected instances and evaluate the correctness of the responses.
Lastly, an often underrated yet vital part of our testing strategy should be logging and monitoring. By implementing these in our application, we can keep an eye on the behavior and performance of our implementations, especially after they go live. This ongoing method of validation is key to identifying and resolving any unexpected issues that might crop up.
Use Cases for Registering Multiple Implementations of the Same Interface
Now that we know how to resolve multiple implementations of the same interface let’s ask ourselves the all-important question: when can we use it?
Multiple implementations of the same interface can greatly enhance functionality and efficiency in various real-world applications. Here are a few to consider.
Data Access Layers
In a large enterprise application with complex data access requirements, multiple implementations of the data access interface can be beneficial. For example, one implementation could leverage a traditional SQL database, while another could use a NoSQL database for specific use cases.
This flexibility allows developers to optimize performance and take advantage of different database technologies.
Authentication Mechanisms
In an ASP.NET Core application, supporting multiple authentication mechanisms such as username/password, social media logins, or token-based authentication, can be achieved by implementing different providers that adhere to a common interface.
This enables users to choose their preferred method while maintaining a consistent authentication workflow.
Payment Gateways
An e-commerce application may require integration with multiple payment gateways, each with unique APIs and features.
By registering multiple implementations of a payment gateway interface, the application can seamlessly switch between providers based on user preferences or specific transaction requirements.
Conclusion
The ability to register and utilize multiple implementations of the same interface in ASP.NET Core can be a tricky task.
All in all, there are several solutions we can utilize to address this issue, including everything from workaround implementations and design modifications to the use of alternative packages.
In the ever-evolving field of software development, mastering the registration and use of multiple interface implementations in ASP.NET Core not only enables the creation of flexible, dynamic, and scalable applications but also enhances our understanding of modern software architecture principles. Hopefully, our exploration has shown it to be a useful tool in the right development scenarios.