Monday, September 14, 2020

Daisy chained dependency injection via constructors with deferred instantiation

 While dependency injection via constructors is a popular and effective form of dependency injection is has several problems.

  1. It forces instantiation of dependency objects that may not be needed
  2. It cannot handle some methods of class A needing class B and some methods of class B needing class A
  3. It can create bloated constructor argument lists

We can get around problems 1 and 2 by using deferred instantiation. That is, instead of passing actual objects we just pass types. The injectee instantiates the class if and when it is needed. Of course, this approach has its own limitations.

  • We cannot enforce the correct base class or interface while editing, but we can at run time
  • We cannot easily specify which instance of the dependency to inject
Let's examine how this works with the same program/configuration/data provider/logging provider architecture I used in the prior blog. Instead of injecting instances of the dependencies, we will inject their types and activate them on demand.

Start a new C# .Net Core project in Visual Studio and call it DIViaType. Add a new class called LoggingProvider. It looks the same as in the prior blog except in our new namespace.

using System;
 
namespace DIViaType
{
    public abstract class BaseLoggingProvider
    {
        public abstract string GetName();
        public abstract void Log(string s);
    }
 
    public class ProductionLoggingProvider : BaseLoggingProvider
    {
        public override string GetName()
        {
            return "Production logging provider";
        }
 
        public override void Log(string s)
        {
            // Write to log file
        }
    }
 
    public class TestLoggingProvider : BaseLoggingProvider
    {
        public override string GetName()
        {
            return "Test logging provider";
        }
 
        public override void Log(string s)
        {
            Console.WriteLine(s);
        }
    }
}

We see the first change in the data provider class. Add a class called DataProvider. The base class exposes a constructor that takes a Type that specifies the logging provider to use. At run time we check that the type is a subclass of BaseLoggingProvider. The data provider has a property called LoggingProvider. The first time this is accessed it will create a logging provider of the specified type. You can see the rest of the code is unchanged. Here it is.


using System;
using System.Data;
using System.Data.SqlClient;
 
namespace DIViaType
{
    public abstract class BaseDataProvider
    {
        private BaseLoggingProvider _loggingProvider = null;
        private Type loggingProviderType;
        protected SqlConnection sqlConnection;
 
        protected BaseDataProvider(Type loggingProviderType = null)
        {
            if (loggingProviderType != null && !loggingProviderType.IsSubclassOf(typeof(BaseLoggingProvider)))
                throw new Exception("Logging provider must be a subclass of BaseLoggingProvider");
 
            this.loggingProviderType = loggingProviderType ?? typeof(ProductionDataProvider);
        }
 
        public BaseLoggingProvider loggingProvider 
        {
            get
            {
                if (_loggingProvider == null)
                {
                    _loggingProvider = (BaseLoggingProvider)Activator.CreateInstance(loggingProviderType);
                }
 
                return _loggingProvider;
            }
        }
 
        public abstract string GetName();
        public abstract void OpenConnection();
        public abstract Settings GetSettings();
        public abstract void CloseConnection();
        public abstract bool IsConnectionOpen();
    }
 
    public class ProductionDataProvider : BaseDataProvider
    {
        public ProductionDataProvider(Type loggingProvider) : base(loggingProvider) { }
 
        public override string GetName()
        {
            return "Production data provider";
        }
 
        public override void CloseConnection()
        {
            loggingProvider.Log("Closing connection");
            sqlConnection.Close();
        }
 
        public override Settings GetSettings()
        {
            loggingProvider.Log("Getting settings");
            if (IsConnectionOpen())
                return new Settings() { isProductionMode = true };
            else
                throw new Exception("Connection is closed");
        }
 
        public override bool IsConnectionOpen()
        {
            return (sqlConnection != null && sqlConnection.State == ConnectionState.Open);
        }
 
        public override void OpenConnection()
        {
            loggingProvider.Log("Opening Connection");
            sqlConnection = new SqlConnection("Server=(local);Database=master;Trusted_Connection=true");
            sqlConnection.Open();
        }
    }
 
    public class TestDataProvider : BaseDataProvider
    {
        private bool isConnectionOpen = false;
 
        public TestDataProvider(Type loggingProvider) : base(loggingProvider) { }
 
        public override string GetName()
        {
            return "Test data provider";
        }
 
        public override void CloseConnection()
        {
            loggingProvider.Log("Closing connection");
            isConnectionOpen = false;
        }
 
        public override Settings GetSettings()
        {
            loggingProvider.Log("Getting settings");
            if (isConnectionOpen)
                return new Settings() { isProductionMode = false };
            else
                throw new Exception("Connection is closed");
        }
 
        public override bool IsConnectionOpen()
        {
            return isConnectionOpen;
        }
 
        public override void OpenConnection()
        {
            loggingProvider.Log("Opening connection");
            isConnectionOpen = true;
        }
    }
}
 

The Configuration class needs to know which data provider to use and also which logging provider the data provider should use. It uses a similar technique as the data provider to allow the calling code to specify the data provider type and logging provider type to use.

Add a class called configuration.The code looks like this.

using System;
 
namespace DIViaType
{
    public class Settings
    {
        public bool isProductionMode;
    }
 
    public class Configuration
    {
        private Type dataProviderType;
        private Type loggingProviderType;
 
        private BaseDataProvider _dataProvider;
 
        public Configuration(Type dataProviderType = null, Type loggingProviderType = null)
        {
            if (dataProviderType != null && !dataProviderType.IsSubclassOf(typeof(BaseDataProvider)))
                throw new Exception("Data provider must be a subclass of BaseDataProvider");
 
            if (loggingProviderType != null && !loggingProviderType.IsSubclassOf(typeof(BaseLoggingProvider)))
                throw new Exception("Logging provider must be a subclass of BaseLoggingProvider");
 
            this.dataProviderType = dataProviderType ?? typeof(ProductionDataProvider);
            this.loggingProviderType = loggingProviderType ?? typeof(ProductionLoggingProvider);
        }
 
        public BaseDataProvider dataProvider    // In real life this is private or protected
        {
            get
            {
                if (_dataProvider == null)
                {
                    _dataProvider = (BaseDataProvider)Activator.CreateInstance(dataProviderType, loggingProviderType);
                }
 
                return _dataProvider;
            }
        }
 
        public Settings GetSettings()
        {
            Settings settings;
            dataProvider.OpenConnection();
            settings = dataProvider.GetSettings();
            dataProvider.CloseConnection();
            return settings;
        }
    }
}
 

By default we use the production providers (which helps with problem 3 - bloated constructors). Modify program.cs to look like this.

using System;
 
namespace DIViaType
{
    class Program
    {
        static void Main(string[] args)
        {
            Configuration configuration = new Configuration();
            Settings settings;
 
            settings = configuration.GetSettings();
            Console.WriteLine(string.Format("Data provider is {0}", configuration.dataProvider.GetName()));
            Console.WriteLine(string.Format("Logging provider is {0}", configuration.dataProvider.loggingProvider.GetName()));
            Console.WriteLine(string.Format("IsProductionMode is {0}", settings.isProductionMode));
        }
    }
}
 
If you run the program now you will see the expected results.


Add an NUnit test project and call it DIViaTypeTest. Change Test1 to DIViaTypeTest and change the code to look like this. We define two tests - the test we expect to pass, and a test that switches the dependencies to demonstrate how we detect this condition. Note the instantiation of the configuration object is more awkward now. On the plus side, we don't have to instantiate a data provider or a logging provider here. In fact, we don't even need to know that the data provider needs a logging provider - slightly improved separation of responsibilities.

using NUnit.Framework;
using DIViaType;
 
namespace DIViaTypeTest
{
    public class Tests
    {
        [Test]
        public void ConfigurationTest()
        {
            Settings settings;
            Configuration configuration = new Configuration(typeof(TestDataProvider), typeof(TestLoggingProvider));
           
            settings = configuration.GetSettings();
 
            Assert.IsFalse(settings.isProductionMode);
            Assert.AreEqual("Test data provider", configuration.dataProvider.GetName());
            Assert.AreEqual("Test logging provider", configuration.dataProvider.loggingProvider.GetName());
        }
 
        [Test]
        public void ConfigurationFailTest()
        {
            Settings settings;
            Configuration configuration = new Configuration(typeof(TestLoggingProvider), typeof(TestDataProvider));
 
            settings = configuration.GetSettings();
 
            Assert.IsFalse(settings.isProductionMode);
        }
    }
}

Running the tests, we see they pass and fail as expected. For those of us who are worried that CreateInstance is slower than New, the test says it only takes one millisecond more.



The impact of the architecture on production code is minimal although it does mean we cannot share instances of dependencies unless they implement a factory, static, or singleton pattern. Instantiation in tests is more awkward, but we have achieved deferred instantiation of dependencies without significantly altering the constructor dependency injection pattern.


No comments:

Post a Comment