In this article, we will learn how to conditionally resolve dependencies in .NET.
Conditional dependency resolution is a powerful technique that allows us to tailor an application’s behavior based on various criteria. We can switch between service implementations depending on environmental factors, configuration settings, build versions, etc.
One common scenario is environment-based configuration, where the application adapts its behavior for different environments like development, testing, or production. Platform-specific integrations, tenant-oriented customization, feature toggling, and pluggable architectures are a few other scenarios where conditional resolution may prove valuable.
Let’s explore this technique in the context of .NET dependency injection.
Entry Point to DI Pipeline
Let’s first prepare our entry point to the dependency injection pipeline in a helper class:
public class Helper { public static IServiceProvider CreateServiceProvider() { var host = Host.CreateDefaultBuilder() .ConfigureServices(ConfigureServices) .Build(); return host.Services; } private static void ConfigureServices(IServiceCollection services) { // TODO: Dependency registrations } }
We set up an IServiceProvider
container that serves as the central component for DI. We are able to do this with the help of the default host builder and the ConfigureServices
delegate. Along the way, we come up with a ConfigureServices()
method where we will register services as we go.
Pre-emptive Conditional Resolution of Dependencies
There are cases when we want an early decision about the desired dependencies prior to their registration within the DI container.
For example, we may support several versions of an API service where the interface remains the same but the concrete implementation varies per version. Or, we may need to provide multiple builds of an application for the sake of backward compatibility. Or, we may need to wire up a certain concrete implementation based on a chosen platform.
Such cases might deal with a specific service variant for the entire application lifetime. Hence, it would be overkill to register all variants within the DI container for each run/build. A better approach is to pick the desired variant beforehand and register only that particular service type.
Conditional Dependency Registration by Compilation Directives
C# compilation directives come in handy when it comes to a multiple builds scenario. As an example, let’s consider two concrete implementations of an IMessageService
interface.
The MessageService
:
public class MessageService : IMessageService { public void Send(string message) { Console.WriteLine(message); } }
And the VerboseMessageService
:
public class VerboseMessageService : IMessageService { public void Send(string message) { Console.WriteLine(message); Console.WriteLine($"Length: {message.Length}. Time: {DateTime.Now:u}"); } }
While MessageService
sends the message as is, its VerboseMessageService
counterpart provides an extra bit of information. We intend to use the verbose variant for Debug
builds only.
Let’s add the registration part inside the ConfigureServices()
method:
private static void ConfigureServices(IServiceCollection services) { RegisterMessageService(services); } private static void RegisterMessageService(IServiceCollection services) { #if DEBUG services.AddTransient<IMessageService, VerboseMessageService>(); #else services.AddTransient<IMessageService, MessageService>(); #endif }
With the help of #if-#else-#endif
directives, we register either VerboseMessageService
or MessageService
depending on the DEBUG
preprocessor symbol. We know that, by default, the DEBUG
symbol is defined when the build is carried out using the Debug
configuration. That means, for builds other than Debug
, the compiler will skip the #if
code-block and vice versa.
Let’s add a simple use of IMessageService
to the Program
class:
var serviceProvider = Helper.CreateServiceProvider(); serviceProvider.GetService<IMessageService>()! .Send("A sample message.");
Once we run the application using two different build configurations (e.g. Debug, Release, etc), we get two different outputs.
With the Debug
configuration:
A sample message. Length: 16. Time: 2023-07-20 07:57:35Z
With the Release
configuration:
A sample message.
This technique of dealing with dependencies at compile time is quite useful. Also, the use of preprocessor symbols allows us to manage such builds by command line scripting. However, such an approach also compromises the testability and maintainability of the application. So, we should not utilize this approach as a general-purpose solution.
Conditional Dependency Registration By Application Configuration
Instead of multiple simultaneous builds, we may want our application as a single build but still adaptable to some pre-defined environmental factors.
Typically such scenarios involve an app configuration file (appsettings.json
):
{ "Sandbox": true }
As we see, our configuration file holds a Sandbox
flag. We aim to utilize this configuration parameter as a deciding factor for choosing a particular IRelayService
implementation.
The LiveRelayService
:
public class LiveRelayService : IRelayService { public string Relay(string message) => $"Live: {message}"; }
The SandboxRelayService
:
public class SandboxRelayService : IRelayService { public string Relay(string message) => $"Sandbox: {message}"; }
With these concrete services in hand, it’s time to set up the IRelayService
registration:
private static void RegisterRelayService(IServiceCollection services) { var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json") .Build(); var sandbox = configuration.GetValue<bool>("Sandbox"); if (sandbox) services.AddTransient<IRelayService, SandboxRelayService>(); else services.AddTransient<IRelayService, LiveRelayService>(); }
At first, we read the configuration data from the app settings file in a non-DI approach, so we can deal with the app configuration before DI registration.
Once we get the value of the Sandbox
parameter, we can easily choose and register the intended service type.
Finally, we can resolve and use an IRelayService
inside the application:
var service = _serviceProvider.GetService<IRelayService>()!; Assert.Equal("Sandbox: Demo", service.Relay("Demo"));
As a result, we always get the output from the SandboxRelayService
because that is what we have chosen in the configuration. We can conveniently switch to the LiveRelayService
for a production environment just by setting the Sandbox
configuration value to false.
Pre-emptive dependency resolution is handy when we need only certain variants of a service throughout the application’s lifetime. It helps us to avoid the redundant dependency registration to the DI container and thus provides a means of optimization. With that said, such an approach won’t help when dependencies vary based on runtime parameters.
Conditionally Resolve Dependencies Using the Factory Pattern
The fact is that most practical scenarios involve the on-demand runtime resolution of dependencies. The Factory Pattern is one of several patterns that help us work with such dynamic resolution.
Let’s consider a scenario where we send notifications to users using two different mediums: Email and SMS.
The EmailAlertService
:
public class EmailAlertService : IAlertService { public string Send(string message) => $"Email: {message}"; }
The SmsAlertService
:
public class SmsAlertService : IAlertService { public string Send(string message) => $"SMS: {message}"; }
Users may choose Email
mode for some notification types and Sms
for others. That means we need both services side by side and need a way to pick one on-demand based on the requested mode.
Resolve With a Factory Delegate
One quick solution is to register a delegate function as a resolver along with the registration of our concrete services:
private static void ConfigureServices(IServiceCollection services) { // omitted for brevity services.AddTransient<SmsAlertService>(); services.AddTransient<EmailAlertService>(); services.AddTransient<Func<AlertMode, IAlertService>>(sp => { return (mode) => mode switch { AlertMode.Email => sp.GetService<EmailAlertService>()!, AlertMode.Sms => sp.GetService<SmsAlertService>()!, _ => throw new NotImplementedException(), }; }); }
The highlighted section is the most interesting bit here. Our AddTransient
method accepts a factory delegate that provides us access to the DI container (an IServiceProvider
instance). Eventually, we return a delegate function that takes a mode parameter and returns the corresponding service instance from the DI container. That’s it!
With this registration in place, we can dynamically pick the desired alert service by mode:
var factory = _serviceProvider.GetService<Func<AlertMode, IAlertService>>()!; var emailService = factory(AlertMode.Email); var smsService = factory(AlertMode.Sms); Assert.Equal("Email: Demo", emailService.Send("Demo")); Assert.Equal("Sms: Demo", smsService.Send("Demo"));
All we need is to request that the DI container provide the delegated resolver we registered, and call that with our desired alert mode.
While our delegate approach offers a quick solution, it suffers from several drawbacks. First of all, it mixes up the application start-up code block with runtime operational logic. It also lacks modularity due to the direct reliance on concrete service types. Every time we introduce a new implementation, we will need to adjust our delegate registration. Lastly, it’s counter-intuitive for the calling code to request a delegate resolution instead of a service instance.
Conditionally Resolve Dependencies With a Factory Class
As a better alternative, we can utilize a factory class instead. Such a class acts as a wrapper and encapsulates all the logic that is responsible for switching between concrete implementations.
Before working through the factory class implementation, let’s bring a few changes to our IAlertService
integrations to make them easily distinguishable by AlertMode
.
We can easily achieve this by introducing a Mode
property to the interface:
public interface IAlertService { AlertMode Mode { get; } string Send(string message); }
However, if for some reason we can’t alter the service interface, we can still achieve this by decorating each concrete class with a custom attribute and tagging it with the appropriate mode.
In our case, the interface way works better. Let’s update our concrete services to implement the new interface member.
The EmailAlertService
:
public class EmailAlertService : IAlertService { public AlertMode Mode => AlertMode.Email; public string Send(string message) => $"Email: {message}"; }
The SmsAlertService
:
public class SmsAlertService : IAlertService { public AlertMode Mode => AlertMode.Sms; public string Send(string message) => $"Sms: {message}"; }
We associate the concrete services to their respective alert mode via the Mode
property. This property serves as a convenient tag to identify the specific service from a list.
All set. It’s time to create our factory class:
public class AlertServiceFactory : IAlertServiceFactory { private readonly IEnumerable<IAlertService> _alertServices; public AlertServiceFactory(IEnumerable<IAlertService> alertServices) { _alertServices = alertServices; } public IAlertService GetAlertService(AlertMode mode) { return _alertServices.FirstOrDefault(e => e.Mode == mode)!; } }
Our factory class works with an IEnumerable<IAlertService>
injection in the constructor. As a result, each factory instance will receive a list of registered IAlertService
implementations from the DI container. From there, we can easily look up the desired service instance by mode.
That said, let’s check the completed registration process:
private static void ConfigureServices(IServiceCollection services) { // omitted for brevity services.AddTransient<IAlertService, SmsAlertService>(); services.AddTransient<IAlertService, EmailAlertService>(); services.AddTransient<IAlertServiceFactory, AlertServiceFactory>(); }
This allows us to deal with the on-demand service resolution in a more straightforward way:
var factory = _serviceProvider.GetService<IAlertServiceFactory>()!; var emailService = factory.GetAlertService(AlertMode.Email); var smsService = factory.GetAlertService(AlertMode.Sms); Assert.Equal("Email: Demo", emailService.Send("Demo")); Assert.Equal("Sms: Demo", smsService.Send("Demo"));
We resolve the factory instance from the DI container and retrieve the desired service variant from the factory – a much cleaner approach than the delegate version.
Resolve Dependencies Conditionally With the Proxy Pattern
The factory class is a widely popular way of resolving conditional dependencies. However, it may not always offer a favorable solution.
Let’s think of an application where we use many product services that rely on an IPricingService
abstraction. And as of now, we only have one basic pricing model in place:
public class StandardPricingService : IPricingService { public decimal Calculate(decimal basePrice, int quantity) { return basePrice * quantity; } }
The StandardPricingService
class provides a basic implementation of theCalculate()
method defined by the IPricingService
interface.
On top of this, we now want to introduce a new pricing model only for our premium users:
public class PremiumPricingService : IPricingService { public decimal Calculate(decimal basePrice, int quantity) { var discount = 0.05m; return basePrice * quantity * (1 - discount); } }
To accommodate this new implementation and also be able to switch to the standard one for basic users, we might consider a factory design just like our previous example. In that case, we would have to replace all IPricingService
injections with the factory and refactor their usage points accordingly. This is clearly not a favorable solution.
Alternatively, we can employ the Proxy Pattern. In this pattern, we provide a wrapper service around available concrete implementations. It implements the same service interface as the original ones. However, unlike actual services, this wrapper service does not provide core business logic. Instead, it conditionally resolves the intended service instance and delegates the request over to that instance:
public class PricingServiceProxy : IPricingService { private readonly StandardPricingService _standardService; private readonly PremiumPricingService _premiumService; public PricingServiceProxy( StandardPricingService standardService, PremiumPricingService premiumService) { _standardService = standardService; _premiumService = premiumService; } public decimal Calculate(decimal basePrice, int quantity) { // Detect whether membership is premium or not var isPremium = false; IPricingService service = isPremium ? _premiumService : _standardService; return service.Calculate(basePrice, quantity); } }
As we can see, our PricingServiceProxy
is just another implementation of IPricingService
. So we don’t need any additional abstraction and refactoring to existing usage areas of IPricingService
. It’s as simple as that.
We only need to make a quick adjustment in the registration area:
private static void ConfigureServices(IServiceCollection services) { // omitted for brevity services.AddTransient<StandardPricingService>(); services.AddTransient<PremiumPricingService>(); services.AddTransient<IPricingService, PricingServiceProxy>(); }
The proxy approach helps us to adapt newer implementations without major refactoring in the application. However, the factory class provides better decoupling as it exhibits a clear separation between the instantiation and the usage of a service. So, it’s important to assess which approach favors the most in the context of a particular feature.
Conditionally Resolve Dependencies Using the Adapter Pattern
Sometimes we need to plug in a bunch of services that are quite different in signature/design, but still interchangeable because of their functional resemblance. This is common when we support multiple third-party API integrations around the same business feature. Since each third-party API provides its own interface, we may need our own single control point that will conditionally choose a specific interface and process accordingly. The Adapter Pattern is a perfect fit for dealing with such situations.
Let’s take an example of a payment system that supports two different gateway integrations.
The GatewayOne
:
public class GatewayOne { public string Pay(decimal amount, string currencyCode) { // API logic return $"GatewayOne: Paid {amount} {currencyCode}"; } }
The GatewayTwo
:
public class GatewayTwo { public string SendPayment(decimal amountInUsd) { // API logic return $"GatewayTwo: Paid {amountInUsd} USD"; } }
GatewayOne
and GatewayTwo
both provide us with an API for payment integration, but nothing is common in their interface.
Let’s plug these gateway services into our gateway adapter class:
public class PaymentGatewayAdapter : IPaymentGatewayAdapter { private readonly GatewayOne _gatewayOne; private readonly GatewayTwo _gatewayTwo; public PaymentGatewayAdapter( GatewayOne gatewayOne, GatewayTwo gatewayTwo) { _gatewayOne = gatewayOne; _gatewayTwo = gatewayTwo; } public string Pay(GatewayType type, decimal amount) { return type switch { GatewayType.One => _gatewayOne.Pay(amount, "USD"), GatewayType.Two => _gatewayTwo.SendPayment(amount), _ => throw new NotImplementedException(), }; } }
We essentially need to inject the supported gateway classes in the constructor. Our adapted version of the Pay()
method takes a GatewayType
parameter and a payment amount. Inside this method, we pick the relevant gateway integration based on the type
and call the corresponding payment processing method with the desired amount and other necessary arguments.
After registering the gateway classes along with the adapter class, we can process payments through both gateways in the same way:
var adapter = _serviceProvider.GetService<IPaymentGatewayAdapter>()!; Assert.Equal("GatewayOne: Paid 20 USD", adapter.Pay(GatewayType.One, 20)); Assert.Equal("GatewayTwo: Paid 20 USD", adapter.Pay(GatewayType.Two, 20));
Caveats of Dynamic Dependencies
Conditional dependency resolution brings customization to the application from various dynamic contexts. It helps the application grow and adapt to new behaviors in a modular, decoupled, testable, and backward-compatible way. Nonetheless, there are certain concerns that we should be aware of.
First of all, conditional dependencies add some overhead to the DI mechanism. Since dynamic resolution involves runtime evaluation, it may cause extra reflections, memory consumption, and complexity in scope management. All these factors contribute to performance overhead in some way. Things get worse when expensive external network/resource-based operations are involved. Such performance issues may not be readily apparent during the design phase, especially when dealing with complex dependency graphs.
In addition, when there are many conditions involved, dependency resolution around such conditions gets inevitably complex. That eventually affects the maintainability and readability of the code base and thus makes debugging and troubleshooting difficult.
Last but not least, a poor choice of design pattern around conditional dependencies can often result in circular dependency problems and lead to unstable application behavior.
Despite all these potential shortcomings, conditional dependency resolution stands out as an effective technique for robust architecture when carefully designed.
Conclusion
In conclusion, this article has explored the techniques to resolve dependencies conditionally in .NET applications. We’ve examined how harnessing the right design patterns can help us effectively manage dynamic dependencies and develop applications that strike the best balance between adaptability and performance.