Friday, September 11, 2020

Daisy chaining dependency injection via constructors

 In yesterday's blog post daisy-chaining-dependency-injection.html I looked ay how we can daisy-chain dependencies via events by pushing requests for dependencies up the chain. It worked quite well, but there was a lot of plumbing required in the base classes.

Today I'm going to look at daisy-chaining dependency injection via the constructor, which is a very popular way to do dependency injection. We will have a very similar scenario. A configuration class uses a data provider which uses a logging provider. We need to be able to substitute a test data provider and a test logging provider during our unit tests.

This walk though will reveal a problem with the technique of daisy-chaining dependency injection through constructors.

Start by creating a C#, .Net Core console project in Visual Studio and call it DaisyChainedDIViaCtors. Add a class called LoggingProvider. We will create a base logging provider class and inherited production and test classes. Note the base class could have been an interface, but the data provider has to have a base class so I'm using base classes at all levels for consistency.

using System;
 
namespace DaisyChainedDIViaCtors
{
    public abstract class BaseLoggingProvider
    {
        abstract public string GetName();
        abstract public 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);
        }
    }
}
 
Next we add a class called DataProvider which defines a base data provider class and test and production inherited classes. The data provider has a logging provider private member that gets populated in the data provider's constructor. If no logging provider is passed to the constructor a production instance is used. This means production code has to work less but it does mean the test code will fail if it's not setup correctly. 

using System.Data.SqlClient;
 
namespace DaisyChainedDIViaCtors
{
    public abstract class BaseDataProvider
    {
        private BaseLoggingProvider _loggingProvider = null;
        public BaseLoggingProvider loggingProvider
        {
            get { return _loggingProvider; }
            private set { _loggingProvider = value; }
        }
 
        public BaseDataProvider(BaseLoggingProvider loggingProvider)
        {
            if (loggingProvider == null)
                this.loggingProvider = new ProductionLoggingProvider();
            else
                this.loggingProvider = 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
    {
        private SqlConnection sqlConnection;
 
        public ProductionDataProvider(BaseLoggingProvider loggingProvider = null) : base(loggingProvider) { }
 
        public override void CloseConnection()
        {
            loggingProvider.Log("Closing connection");
            sqlConnection.Close();
            sqlConnection = null;
        }
 
        public override string GetName()
        {
            return "Production data provider";
        }
 
        public override Settings GetSettings()
        {
            loggingProvider.Log("Getting settings");
            if (IsConnectionOpen())
                return new Settings() { isProductionMode = true };
            else
                throw new System.Exception("Connection is not open");
        }
 
        public override bool IsConnectionOpen()
        {
            return (sqlConnection != null && sqlConnection.State == System.Data.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(BaseLoggingProvider loggingProvider = null) : base(loggingProvider) { }
 
        public override void CloseConnection()
        {
            loggingProvider.Log("Closing connection");
            isConnectionOpen = false;
        }
 
        public override string GetName()
        {
            return "Test data provider";
        }
 
        public override Settings GetSettings()
        {
            loggingProvider.Log("Getting settings");
            if (IsConnectionOpen())
                return new Settings() { isProductionMode = false };
            else
                throw new System.Exception("Connection is not open");
        }
 
        public override bool IsConnectionOpen()
        {
            return (isConnectionOpen);
        }
 
        public override void OpenConnection()
        {
            loggingProvider.Log("Opening connection");
            isConnectionOpen = true;
        }
    }
}
 
Now we add the class we will be testing. Add a class called Configuration. Its constructor will take two optional parameters - a data provider and a logging provider. If an argument is not passed the production provider is instantiated. If the configuration class doesn't need to log directly we could move loggingProvider into the constructor.

namespace DaisyChainedDIViaCtors
{
    public class Settings
    {
        public bool isProductionMode;
    }
 
    public class Configuration
    {
        private BaseDataProvider dataProvider;
 
        public Configuration(BaseDataProvider dataProvider = null)
        {
            if (dataProvider is null)
                this.dataProvider = new ProductionDataProvider(new ProductionLoggingProvider());
            else
                this.dataProvider = dataProvider;
        }
 
        public string GetDataProviderName()
        {
            return dataProvider.GetName();
        }
 
        public string GetDataProviderLoggingProviderName()
        {
            return dataProvider.loggingProvider.GetName();
        }
 
        public Settings GetSettings()
        {
            Settings settings;
 
            dataProvider.OpenConnection();
            settings= dataProvider.GetSettings();
            dataProvider.CloseConnection();
            return settings;
        }
    }
}

Let's put some code into Program.cs to instantiate Configuration in production mode and exercise it.
using System;
 
using System;
 
namespace DaisyChainedDIViaCtors
{
    class Program
    {
        static void Main(string[] args)
        {
            bool isProductionMode = false;
 
            // Instantiate configuration to use a production data provider and a production logging provider
            Configuration configuration = new Configuration();
            isProductionMode = configuration.GetSettings().isProductionMode;
 
            Console.WriteLine(string.Format("IsProductionMode is {0}", isProductionMode));
            Console.WriteLine(string.Format("DataProvider is {0}", configuration.GetDataProviderName()));
            Console.WriteLine(string.Format("DataProvider's Logging Provider is {0}", configuration.GetDataProviderLoggingProviderName()));
        }
    }
}
 
The output tells us we are using the production version of the data provider and it is using the production version of the logging provider.




Lets do some testing. Add a new NUnit test project to the solution and call it DaisyChainedDIViaCtorsTest. Rename the unit test class to DaisyChainedDIViaCtorsTest. We will write a test that will instantiate Configuration with a test data provider and test logging provider.

using NUnit.Framework;
using DaisyChainedDIViaCtors;
 
namespace DaisyChainedDIViaCtorsTest
{
    public class Tests
    {
        [Test]
        public void TestProvidersTest()
        {
            bool IsProductionMode = false;
 
            // Instantiate Configuration to use a test data provider and a test logging provider
            BaseLoggingProvider loggingProvider = new TestLoggingProvider();
            BaseDataProvider dataProvider = new TestDataProvider(loggingProvider);
            Configuration configuration = new Configuration(dataProvider: dataProvider);
 
            IsProductionMode = configuration.GetSettings().isProductionMode;
 
            Assert.IsFalse(IsProductionMode);
            Assert.AreEqual("Test data provider", configuration.GetDataProviderName());
            Assert.AreEqual("Test logging provider", configuration.GetDataProviderLoggingProviderName());
        }
    }
}


Run the test and you will see it passes.




This test is important for two reasons. First, it is the most likely scenario. Second, it highlights a problem with this approach.

Because the unit test instantiates the data provider, it is responsible for injecting the desired dependencies. In this case it is only a logging provider. This means the logging provider must be instantiated first, then used to instantiate the data provider. But what if some methods in the logging provider needed a data provider? We would have to inject a data provider into the logging provider via its constructor. How would we do this?

Dependency Injection via the constructor does not provide enough granularity to deal with this scenario on its own. We can work around this with method or property dependency injection but if we have to use those too, why not use them only?

Perhaps there's a way to do dependency injection via the constructor but not by passing actual instances. Perhaps we could just pass enough information for the class to create the instances if and when it needs to. I'll be examining this possibility later.



No comments:

Post a Comment