When Not To Use DI, IoC, and IoC Containers in .NET C#
top of page

Subscribe to get best practices, tutorials, and many other cool things directly to your email inbox

  • Writer's pictureAhmed Tarek

When Not To Use DI, IoC, and IoC Containers in .NET C#

Updated: Apr 17

Know when DIs aren’t the right solution, and the better design to use instead in .NET C#


Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering
Photo by Olav Ahrens Røtne on Unsplash

Throughout my years of working as a Software Engineer, I came across many occasions where I couldn’t understand the code I am looking at.


First, I thought that this is originating from a lack of knowledge from my side or my skills are not sharp enough and this was always pushing me to learn more and more.


However, after years and years of learning and implementing, I was surprised that from time to time I am still facing the same problem. How come even after 12+ years of being a Software Engineer, I am still having the same exact problem?!


Someone might answer this question that may be the code you are looking at is actually too bad and that’s why it would be so hard for anyone to understand, maybe even to the one who wrote it.


Unfortunately, no. Believe me, I hoped that this is the answer but this is not the case I am talking about. The code I am referring to is actually, by today’s standards, is perfect.


Then what?!!


 

The Finding


When I looked closely into it, and I invested some quiet time doing this, I found one of the most important findings in my life as a Software Engineer.


Dependency Injection (DI), Inversion of Control (IoC), and IoC Containers are our friends, but like everything in life, if you abuse using them, you would get what you don’t ever wish for.


Before DI and IoC Containers, it was a hell to manage dependencies between different modules/classes and that’s why we were more careful and cautious about defining these dependencies. We used to think twice or even more about each module/class dependency before starting the implementation.


However, now after having DI, IoC, and IoC Containers, defining a dependency became like breathing, you implicitly do it when you actually don’t recognize it.


 


 

Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering
Photo by Tim Mossholder on Unsplash

Now What?


To understand what I actually mean, I would walk you through a practical example. I know you love code, so, why not start coding and see how it works?


However, Disclaimer!

  1. The code you are going to see is not perfect. Some best practices were intentionally ignored for the sake of demonstration and driving your focus on the other best practices we are aiming at in this article.

  2. In software, there is always room for a compromise and by 90% you can have your own design restrictions. Therefore, please study what you would find in the code, analyze it, and see what suits your own case.

  3. I am not an expert with Taxes and have zero practical experience with them. Therefore, please excuse my dummy improvised calculations you would find in the code 🙂


 

Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering
Photo by Fotis Fotopoulos on Unsplash

Trip Down the Code Lane


Our simple example here is about the software that calculates Taxes. We have two types of defined Taxes; VAT and Income.


Now, let’s start implementing the code.


 

ILogger


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace TaxesCalculator.Abstractions
{
    public interface ILogger
    {
        void LogMessage(string message);
    }
}

What we can notice here:

  1. This is the interface ILogger.

  2. It represents every logger we could have in the solution.

  3. It defines only one method with the header double void LogMessage(string message);


 

ITaxCalculator


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace TaxesCalculator.Abstractions
{
    public interface ITaxCalculator
    {
        double CalculateTaxPerMonth(double monthlyIncome);
    }
}

What we can notice here:

  1. This is the interface ITaxCalculator.

  2. It represents every Tax calculator we could have in the solution.

  3. It defines only one method with the header double CalculateTaxPerMonth(double monthlyIncome);


 

ConsoleLogger


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TaxesCalculator.Abstractions;

namespace TaxesCalculator.Implementations.Loggers
{
    public class ConsoleLogger : ILogger
    {
        public void LogMessage(string message)
        {
            Console.WriteLine(message);
        }
    }
}

What we can notice here:

  1. This is a ConsoleLogger class implementing ILogger.

  2. It wraps System.Console class and uses it to write to the Console. This is not the perfect implementation but it would be enough for now in order to drive your focus on the current scope.

  3. If you like to know more about a better design and implementation, you can check this story: How to Fully Cover .NET C# Console Application With Unit Tests.


 

IncomeTaxCalculator


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TaxesCalculator.Abstractions;

namespace TaxesCalculator.Implementations.TaxCalculators
{
    public class IncomeTaxCalculator : ITaxCalculator
    {
        private readonly ILogger m_Logger;

        public IncomeTaxCalculator(ILogger logger)
        {
            m_Logger = logger;
        }

        public double CalculateTaxPerMonth(double monthlyIncome)
        {
            // Do some interesting calculations
            var tax = monthlyIncome * 0.5;

            // Don't forget to log the message
            m_Logger.LogMessage($"Calculated Income Tax per month for Monthly Income: {monthlyIncome} equals {tax}");

            return tax;
        }
    }
}

What we can notice here:

  1. This is an IncomeTaxCalculator class implementing ITaxCalculator.

  2. It depends on the ILogger to be able to log some important messages about the calculations.

  3. That’s why the ILogger is injected into the constructor.

  4. In the CalculateTaxPerMonth method implementation, we just do the calculations and log the message using the injected ILogger.


 

VatTaxCalculator


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using TaxesCalculator.Abstractions;

namespace TaxesCalculator.Implementations.TaxCalculators
{
    public class VatTaxCalculator : ITaxCalculator
    {
        private readonly ILogger m_Logger;

        public VatTaxCalculator(ILogger logger)
        {
            m_Logger = logger;
        }

        public double CalculateTaxPerMonth(double monthlyIncome)
        {
            var tax = 0.0;

            // Do some complex calculations on more than one step

            // Step1
            tax += monthlyIncome * 0.0012;
            m_Logger.LogMessage($"VAT Calculation Step 1, Factor: {monthlyIncome * 0.0012}, Total: {tax}");

            // Step2
            tax += monthlyIncome * 0.003;
            m_Logger.LogMessage($"VAT Calculation Step 2, Factor: {monthlyIncome * 0.003}, Total: {tax}");

            // Step3
            tax += monthlyIncome * 0.00005;
            m_Logger.LogMessage($"VAT Calculation Step 3, Factor: {monthlyIncome * 0.00005}, Total: {tax}");

            // Don't forget to log the final message
            m_Logger.LogMessage($"Calculated Vat Tax per month for Monthly Income: {monthlyIncome} equals {tax}");

            return tax;
        }
    }
}

What we can notice here:

  1. This is an VatTaxCalculator class implementing ITaxCalculator.

  2. It depends on the ILogger to be able to log some important messages about the calculations.

  3. That’s why the ILogger is injected into the constructor.

  4. In the CalculateTaxPerMonth method implementation, we just do the calculations and log the message using the injected ILogger.

  5. The different thing here is that the calculations are more complex, it takes 3 steps to complete the calculations, and for each step we need to log important information.


 


 

Program


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Autofac;
using TaxesCalculator.Abstractions;
using TaxesCalculator.Implementations.Loggers;
using TaxesCalculator.Implementations.TaxCalculators;

namespace TaxesCalculator
{
    class Program
    {
        private static IContainer Container;

        static void Main(string[] args)
        {
            var builder = new ContainerBuilder();
            builder.RegisterType<ConsoleLogger>().As<ILogger>();
            builder.RegisterType<IncomeTaxCalculator>().As<ITaxCalculator>();
            builder.RegisterType<VatTaxCalculator>().As<ITaxCalculator>();
            Container = builder.Build();

            using (var scope = Container.BeginLifetimeScope())
            {
                var logger = scope.Resolve<ILogger>();
                var taxCalculators = scope.Resolve<IEnumerable<ITaxCalculator>>();
                var monthlyIncome = 2000.0;
                var totalTax = 0.0;

                foreach (var taxCalculator in taxCalculators)
                {
                    totalTax += taxCalculator.CalculateTaxPerMonth(monthlyIncome);
                }

                logger.LogMessage($"Total Tax for Monthly Income: {monthlyIncome} equals {totalTax}");
            }

            Console.ReadLine();
        }
    }
}

What we can notice here:

  1. This is the Program class. It is the main entry point to the whole application, which is, by the way, a C# Console Application.

  2. We are using AutoFac IoC Container, so you will need to install it from the Nuget package manager.

  3. In the Main method, the first thing we are doing is that we are initializing the IoC container, defining our abstractions-implementations pairs, and creating our IoC container scope.

  4. Inside the scope, we are resolving an instance of the ILogger, and a list of all available ITaxCalculator implementations.

  5. Then we are using all the Tax Calculators to get the sum of all combined Taxes.

  6. And finally logging a message.


 

When running the application, we would get the result in the image below.


Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering

Great, the application is working as expected, we have defined our dependencies, we are using DI, IoC, and IoC Containers… perfect.


Ok, you might find this perfect and easy to read and understand. However, what if you have too many modules, too many loggers, too many calculators,…


 


 

Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering
Photo by Emily Morter on Unsplash

What If?


  • you want to control the whole bandwidth of the logging process? The count of lines to be logged every one hour?

  • you want to migrate your design to microservices?

  • the logging service is down and you want to keep track of the missed logs

  • you want to use something like a message bus?


What if…


We have a lot of “What ifs”, and don’t get me wrong, I understand that these were not a part of the requirements in the first place. So, I am not blaming you for not considering these into the design.


However, what is concerning me here is:

  1. The design is not ready for these kinds of requirements.

  2. You would need to apply too many changes to adapt to the new needs.

  3. Even if you are not designing your logger and calculators to be separate isolated microservices, this doesn’t mean that a Tax Calculator should somehow depend on a Logger.

  4. A calculator can make use of a logger, but, it should also be able to do its job if a logger is not there.

  5. I hear someone saying that then we can modify the implementation and make the logger as optional, check if it is passed or null and so on,….

  6. Even if we apply this, besides the bad implementation and checking for nulls,… still the Calculator module/class knows about something called logger which is illogical.

  7. Also, using the current design, you can’t separate between doing the calculations and logging the messages. Besides, there is no one point of control that monitors the flow of messages coming from all the calculators. This makes you lose the edge of being able to do aggregations, applying thresholds,…

  8. Additionally, if someone new joins the team and starts looking into the code, he would end up looking into a huge web of illogical dependencies.


Again, I know that you should not apply complicated designs based on dreams of what could come in the next 50 years or something. However, we sometimes oversimplify things when actually applying simple best practices would make the whole design more robust and dependable.


 

Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering
Photo by Sharon McCutcheon on Unsplash

Then What?


What you can do is simply change the way you think about dependencies.

Yes, we know that a dependency is coupled with an implementation, not with an abstraction. But still, do you think that the implementation IncomeTaxCalculator should be dependent on a logger?


I can understand that an SQLDatabaseRepository class implementation, which implements IRepository interface, would -by definition- depend on some module that opens and closes an SQL database connection. This is something that you can easily say with full trust.


However, you can’t easily say, with the same level of trust, that the same SQLDatabaseRepository class implementation depends on a logger module, right?


 


 

Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering
Photo by Silvan Arnet on Unsplash

The Bulls-Eye


How to Do it Right?

With some simple changes to the design, we can make it happen. So, let’s dive into the code.


 

ITaxCalculator


namespace TaxesCalculator.Abstractions
{
    public delegate void TaxCalculationReportReadyEventHandler(object sender, string report);

    public interface ITaxCalculator
    {
        event TaxCalculationReportReadyEventHandler TaxCalculationReportReady;
        double CalculateTaxPerMonth(double monthlyIncome);
    }
}

What we can notice here:

  1. We applied a change to the ITaxCalculator interface.

  2. We defined a delegate of type TaxCalculationReportReadyEventHandler.

  3. We defined a new member in the interface as an event of type TaxCalculationReportReadyEventHandler.

  4. This event would be used to raise messages to any subscriber whenever a log message is ready from any Tax Calculator.


 

TaxCalculatorBase


using TaxesCalculator.Abstractions;

namespace TaxesCalculator.Implementations.TaxCalculators
{
    public abstract class TaxCalculatorBase : ITaxCalculator
    {
        public event TaxCalculationReportReadyEventHandler TaxCalculationReportReady;

        public abstract double CalculateTaxPerMonth(double monthlyIncome);

        protected void OnTaxCalculationReportReady(string report)
        {
            TaxCalculationReportReady?.Invoke(this, report);
        }
    }
}

What we can notice here:

  1. We defined the new base class TaxCalculatorBase for all ITaxCalculators implementations.

  2. This class would provide a common implementation of the protected OnTaxCalculationReportReady method which is responsible for internal triggering the TaxCalculationReportReady event. This is one of the best practices advised by Microsoft.


 

IncomeTaxCalculator


namespace TaxesCalculator.Implementations.TaxCalculators
{
    public class IncomeTaxCalculator : TaxCalculatorBase
    {
        public override double CalculateTaxPerMonth(double monthlyIncome)
        {
            // Do some interesting calculations
            var tax = monthlyIncome * 0.5;

            // Don't forget to report
            OnTaxCalculationReportReady(
                $"Calculated Income Tax per month for Monthly Income: {monthlyIncome} equals {tax}");

            return tax;
        }
    }
}

What we can notice here:

  1. Now the IncomeTaxCalculator class extends the TaxCalculatorBase class instead of the ITaxCalculator interface.

  2. It doesn’t depend anymore on the ILogger interface as it used to be in the old implementation.

  3. Now, whenever it needs to report a message, it triggers the TaxCalculationReportReady event instead of directly using an instance of the ILogger interface.


 

VatTaxCalculator


namespace TaxesCalculator.Implementations.TaxCalculators
{
    public class VatTaxCalculator : TaxCalculatorBase
    {
        public override double CalculateTaxPerMonth(double monthlyIncome)
        {
            var tax = 0.0;

            // Do some complex calculations on more than one step

            // Step1
            tax += monthlyIncome * 0.0012;
            OnTaxCalculationReportReady($"VAT Calculation Step 1, Factor: {monthlyIncome * 0.0012}, Total: {tax}");

            // Step2
            tax += monthlyIncome * 0.003;
            OnTaxCalculationReportReady($"VAT Calculation Step 2, Factor: {monthlyIncome * 0.003}, Total: {tax}");

            // Step3
            tax += monthlyIncome * 0.00005;
            OnTaxCalculationReportReady($"VAT Calculation Step 3, Factor: {monthlyIncome * 0.00005}, Total: {tax}");

            // Don't forget to log the final message
            OnTaxCalculationReportReady(
                $"Calculated Vat Tax per month for Monthly Income: {monthlyIncome} equals {tax}");

            return tax;
        }
    }
}

The same kind of changes as in IncomeTaxCalculator class.


 

Program


using System;
using System.Collections.Generic;
using Autofac;
using TaxesCalculator.Abstractions;
using TaxesCalculator.Implementations.Loggers;
using TaxesCalculator.Implementations.TaxCalculators;

namespace TaxesCalculator
{
    class Program
    {
        private static ILogger Logger;
        private static IContainer Container;

        static void Main(string[] args)
        {
            var builder = new ContainerBuilder();
            builder.RegisterType<ConsoleLogger>().As<ILogger>();
            builder.RegisterType<IncomeTaxCalculator>().As<ITaxCalculator>();
            builder.RegisterType<VatTaxCalculator>().As<ITaxCalculator>();
            Container = builder.Build();

            using (var scope = Container.BeginLifetimeScope())
            {
                Logger = scope.Resolve<ILogger>();
                var taxCalculators = scope.Resolve<IEnumerable<ITaxCalculator>>();
                var monthlyIncome = 2000.0;
                var totalTax = 0.0;

                foreach (var taxCalculator in taxCalculators)
                {
                    taxCalculator.TaxCalculationReportReady += (sender, report) => LogTaxReport(report);
                    totalTax += taxCalculator.CalculateTaxPerMonth(monthlyIncome);
                }

                Logger.LogMessage($"Total Tax for Monthly Income: {monthlyIncome} equals {totalTax}");
            }

            Console.ReadLine();
        }

        private static void LogTaxReport(string report)
        {
            Logger.LogMessage(report);
        }
    }
}

What we can notice here:

  1. The main change here is subscribing to the TaxCalculationReportReady event for each ITaxCalculator interface implementation.

  2. And handling this in a centralized LogTaxReport method.

  3. Now, the knowledge about the need of Tax calculations and logging resides in the right place.

  4. We can now easily control the different modules in the solution, collect all required information, do collective decisions,…

  5. Simply, knock yourself out, you can do whatever you want.


 

Running the application, we would get the result as before as in the image below:


Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering

So, now we have the same result but with different design and extended capabilities.


Now, we can easily adapt the design to add new features as we wished for in the “What If” section above, easy and clean…


Learn the best practice on when to use Dependency Injection (DI), Inversion of Control (IoC) and IoC Containers. DotNet (.NET) CSharp (C#). Best Practice Code Coding Programming Software Development Architecture Engineering
Photo by Grégoire Bertaud on Unsplash

At the end, I want to stress something — in the software world, you keep growing day by day and you should always keep your eyes focused on what to learn next. It is never too late to learn.


Finally, hope you found reading this article as interesting as I found writing it.



Recent Posts

See All

Subscribe to get best practices, tutorials, and many other cool things directly to your email inbox

bottom of page
Mastodon Mastodon