Dependency injection – easier than it sounds?

Hello, Habr!

We are preparing to release the second edition of the legendary book by Mark Siman, “Dependency injection on the .NET platform”

Therefore, today we decided to briefly refresh the topic of dependency injection for specialists in .NET and C # and offer a translation of Graham Downs article, where this paradigm is considered in the context of management inversion (IoC) and container use

Most programmers probably know the phenomenon of Dependency Injection, but not everyone understands the meaning of it. You probably had to deal with interfaces and containers, and sometimes working with them led you to a dead end. On the other hand, you may have just heard something about dependency injection, and opened this article because you want to better understand their essence. In this article, I will show how simple the concept of dependency injection is, and how powerful it can actually be.

Dependency injection is a self-contained approach that can be used on its own. On the other hand, this approach can be applied both with interfaces and with containers for dependency injection / management inversion (DI / IoC). When applying dependency injection in this context, you may encounter some confusion, which I initially experienced.

Throughout my career (I specialize in development in Net / C #), I used to use dependency injection in its purest form. At the same time, I implemented DI without resorting to either containers or inversion of control at all. Everything changed recently when I was given a task in which the use of containers was indispensable. Then I strongly doubted everything that I knew before.

Having worked in this style for several weeks, I realized that containers and interfaces do not complicate the implementation of dependencies, but, on the contrary, expand the capabilities of this paradigm.

(It is important to note here: interfaces and containers are used only in the context of dependency injection. Dependency injection can be implemented without interfaces / containers, but, in essence, the only purpose of interfaces or containers is to facilitate the implementation of dependencies).

This article will show how dependency injection is done, both with and without interfaces and containers. I hope that after reading it, you will clearly understand the principle of dependency injection and will be able to make informed decisions about when and where to resort to using interfaces and containers when implementing dependencies.

Training

To better understand dependency injection in its purest form, let’s look at an example application written in C #.

First, note that such an application could be written without any dependency injection. Then we will introduce dependency injection into it, adding a simple logging feature.

As you progress, you will see how the requirements for logging are gradually becoming more complicated, and we satisfy these requirements using dependency injection; while the area of ​​responsibility of the class Calculator minimized. Dependency injection also eliminates the need to modify the class. Calculator whenever we want to change the logging device.

application

Consider the following code. It is written for a simple calculator application that takes two numbers, an operator, and displays the result. (This is a simple working command line application, so it’s easy for you to reproduce it as a C # Console Application in Visual Studio and paste the code in there if you want to follow the example. Everything should work without problems.)

We have a class Calculator and main class Programusing it.

Program.cs:

using System;
using System.Linq;

namespace OfferZenDiTutorial
{
    class Program
    {
        static void Main(string[] args)
        {
            var number1 = GetNumber("Enter the first number: > ");
            var number2 = GetNumber("Enter the second number: > ");
            var operation = GetOperator();
            var calc = new Calculator();
            var result = GetResult(calc, number1, number2, operation);
            Console.WriteLine($"{number1} {operation} {number2} = {result}");
            Console.Write("Press any key to continue...");
            Console.ReadKey();
        }

        private static float GetNumber(string message)
        {
            var isValid = false;
            while (!isValid)
            {
                Console.Write(message);
                var input = Console.ReadLine();
                isValid = float.TryParse(input, out var number);
                if (isValid)
                    return number;

                Console.WriteLine("Please enter a valid number. Press ^C to quit.");
            }

            return -1;
        }

        private static char GetOperator()
        {
            var isValid = false;
            while (!isValid)
            {
                Console.Write("Please type the operator (/*+-) > ");
                var input = Console.ReadKey();
                Console.WriteLine();
                var operation = input.KeyChar;
                if ("/*+-".Contains(operation))
                {
                    isValid = true;
                    return operation;
                }

                Console.WriteLine("Please enter a valid operator (/, *, +, or -). " +
                                  "Press ^C to quit.");
            }

            return ' ';
        }

        private static float GetResult(Calculator calc, float number1, float number2, 
            char operation)
        {
            switch (operation)
            {
                case "https://habr.com/": return calc.Divide(number1, number2);
                case '*': return calc.Multiply(number1, number2);
                case '+': return calc.Add(number1, number2);
                case '-': return calc.Subtract(number1, number2);
                default:
                    // Такого произойти не должно, если с предыдущими валидациями все было нормально 
                    throw new InvalidOperationException("Invalid operation passed: " + 
                                                        operation);
            }
        }
    }
}

The main program starts, asks the user for two numbers and an operator, and then calls the class Calculator to perform a simple arithmetic operation on these numbers. Then displays the result of the operation. Here is the class Calculator.

Calculator.cs:

namespace OfferZenDiTutorial
{
    public class Calculator
    {
        public float Divide(float number1, float number2)
        {
            return number1 / number2;
        }

        public float Multiply(float number1, float number2)
        {
            return number1 * number2;
        }

        public float Add(float number1, float number2)
        {
            return number1 + number2;
        }

        public float Subtract(float number1, float number2)
        {
            return number1 - number2;
        }
    }
}

Logging

The application works fine, but just imagine: your boss thought that now all operations should be logged into a file on disk so that you can see what people are doing.

It seems that this is not so difficult, right? Take and add instructions, according to which all operations performed in Calculatormust be entered in a text file. This is how your now looks Calculator:

Calculator.cs:

using System.IO;

namespace OfferZenDiTutorial
{
    public class Calculator
    {
        private const string FileName = "Calculator.log";

        public float Divide(float number1, float number2)
        {
            File.WriteAllText(FileName, $"Running {number1} / {number2}");
            return number1 / number2;
        }

        public float Multiply(float number1, float number2)
        {
            File.WriteAllText(FileName, $"Running {number1} * {number2}");
            return number1 * number2;
        }

        public float Add(float number1, float number2)
        {
            File.WriteAllText(FileName, $"Running {number1} + {number2}");
            return number1 + number2;
        }

        public float Subtract(float number1, float number2)
        {
            File.WriteAllText(FileName, $"Running {number1} - {number2}");
            return number1 - number2;
        }
    }
}

It works great. Whenever in Calculator anything happens, it writes it to a file Calculator.loglocated in the same directory from where it starts.

But, the question is possible: is it really appropriate for the Calculator class to be responsible for writing to a text file?

Class filelogger

Not. Of course, this is not his area of ​​responsibility. Therefore, so as not to be disturbed principle of sole responsibility, Everything related to information logging should occur in the log file, and for this it is necessary to write a separate self-sufficient class. Let’s do it.

First we create a completely new class, call it FileLogger. This is how it will look.

FileLogger.csh:

using System;
using System.IO;

namespace OfferZenDiTutorial
{
    public class FileLogger
    {
        private const string FileName = "Calculator.log";
        private readonly string _newLine = Environment.NewLine;

        public void WriteLine(string message)
        {
            File.AppendAllText(FileName, $"{message}{_newLine}");
        }
    }
}

Now everything related to creating a log file and writing information to it is processed in this class. In addition, we get one pleasant trifle: no matter what this class consumes, it is not necessary to put blank lines between separate records. Records should just call our method WriteLine, and we take care of the rest. Isn’t it cool?
To use a class, we need an object that instantiates it. Let’s solve this problem inside the class Calculator. Replace the contents of the class Calculator.cs as follows:

Calculator.cs:

namespace OfferZenDiTutorial
{
    public class Calculator
    {
        private readonly FileLogger _logger;

        public Calculator()
        {
            _logger = new FileLogger();
        }

        public float Divide(float number1, float number2)
        {
            _logger.WriteLine($"Running {number1} / {number2}");
            return number1 / number2;
        }

        public float Multiply(float number1, float number2)
        {
            _logger.WriteLine($"Running {number1} * {number2}");
            return number1 * number2;
        }

        public float Add(float number1, float number2)
        {
            _logger.WriteLine($"Running {number1} + {number2}");
            return number1 + number2;
        }

        public float Subtract(float number1, float number2)
        {
            _logger.WriteLine($"Running {number1} - {number2}");
            return number1 - number2;
        }
    }
}

So, now our calculator doesn’t care how exactly the new logger writes the information to the file, or where this file is located, and whether the file is written at all. However, there is still one problem: can we even count on the fact that the class Calculator will know how to create a logger?

Dependency Injection

Obviously, the answer to the last question is negative!

It is here, dear reader, that addiction comes into play. Let’s change the constructor of our class Calculator:

Calculator.cs:

        public Calculator(FileLogger logger)
        {
            _logger = logger;
        }

That’s all. Nothing else changes in the class.

Dependency injection is an element of a larger topic called Management Management, but a detailed discussion of it is beyond the scope of this article.

In this case, you just need to know that we invert managing the logger class, or, metaphorically speaking, delegating to someone the problem of creating a file FileLoggerintroducing an instance FileLogger into our calculator, not counting that class Calculator he will know how to create it.

So whose responsibility is this?

Just the one who instantiates the class Calculator. In our case, this is the main program.

To demonstrate this, modify the Main method in our Program.cs class as follows:

Program.cs

  static void Main(string[] args)
        {
            var number1 = GetNumber("Enter the first number: > ");
            var number2 = GetNumber("Enter the second number: > ");
            var operation = GetOperator();
            // Следующие две строки изменены
            var logger = new FileLogger();
            var calc = new Calculator(logger);
            var result = GetResult(calc, number1, number2, operation);
            Console.WriteLine($"{number1} {operation} {number2} = {result}");
            Console.Write("Press any key to continue...");
            Console.ReadKey();
        }

Thus, only two lines need to be changed. We do not expect class Calculator instantiate FileLoggerit will do for him Main, and then pass it the result.

In essence, this is dependency injection. Neither interfaces, nor containers for inversion of control, nor anything like that are needed. Basically, if you had to do something like that, then you were dealing with dependency injection. Cool, right?

Expansion of possibilities: we will make another logger

Despite the foregoing, interfaces have their own place, and in fact they are revealed precisely in conjunction with Dependency Injection.

Suppose you have a client, from the point of view of which the logging of each call to Calculator – a waste of time and disk space, and it’s better not to log anything at all.

Do you think it’s necessary to make changes inside Calculator, what would potentially require recompiling and redistributing the assembly in which it is located?

This is where the interfaces come in handy.

Let’s write an interface. Let’s call him ILogger, since our class will be engaged in its implementation FileLogger.

ILogger.cs

namespace OfferZenDiTutorial
{
    public interface ILogger
    {
        void WriteLine(string message);
    }
}

As you can see, it defines a single method: WriteLineimplemented FileLogger. Let’s take another step and formalize these relations, making sure that this class officially implements our new interface:

Filelogger.cs

public class FileLogger : ILogger

This is the only change we will make to this file. Everything else will be as before.
So, we determined the attitude – what should we do with it now?

First, let’s change the Calculator class so that it uses the interface ILoggerrather than a specific implementation FileLogger:

Calculator.cs

private readonly ILogger _logger;

        public Calculator(ILogger logger)
        {
            _logger = logger;
        }

At this point, the code is still compiled and executed without any problems. We pass into it FileLogger from the main method of the program, one that implements ILogger. The only difference is that Calculator not just no need to know how to create FileLogger, but even what kind of logger is given to him.

Because whatever you get implements an interface ILogger (and therefore has a method WriteLine), with practical use there are no problems.

Now let’s add another implementation of the interface ILogger. This will be the class that does nothing when the method is called. WriteLine. We will call him NullLogger, and here is what it looks like:

NullLogger.cs

namespace OfferZenDiTutorial
{
    public class NullLogger : ILogger
    {
        public void WriteLine(string message)
        {
            // Ничего не делаем в этой реализации
        }
    }
}

This time we don’t need to change anything in the class Calculatorif we are going to use the new NullLogger, because he already accepts anything that implements the interface ILogger.

We only need to change the Main method in our Program.cs file to pass another implementation to it. Let’s do this, so that the method Main took the following form:

Program.cs

 static void Main(string[] args)
        {
            var number1 = GetNumber("Enter the first number: > ");
            var number2 = GetNumber("Enter the second number: > ");
            var operation = GetOperator();
            var logger = new NullLogger(); // Эту строку нужно изменить
            var calc = new Calculator(logger);
            var result = GetResult(calc, number1, number2, operation);
            Console.WriteLine($"{number1} {operation} {number2} = {result}");
            Console.Write("Press any key to continue...");
            Console.ReadKey();
        }

Again, you only need to change the line that I commented out. If we want to use a different logging mechanism (for example, one in which information is recorded in the Windows event log, or using SMS notifications or email notifications), then we just need to transfer a different implementation
interface ILogger.

Small disclaimer about interfaces

As you can see, interfaces are a very powerful tool, but they bring an additional level of abstraction to the application, and, therefore, extra complexity. Containers can help in the fight against complexity, but, as will be shown in the next section, you have to register all your interfaces, and when working with specific types, this is not required.

There is the so-called “obfuscation with the help of abstraction”, which, in essence, boils down to a re-complication of the project in order to realize all these different levels. If you want, you can do without all of these interfaces, unless there are specific reasons why you need them. In general, you should not create such an interface that has only one implementation.

Dependency Injection Containers

The example worked out in this article is quite simple: we are dealing with a single class that has only one dependency. Now suppose we have many dependencies, and each of them is associated with other dependencies. Even when working on moderately complex projects, it is likely that you will have to deal with such situations, and it will not just keep in mind that you need to create all these classes, but also remember which one depends on which one – especially if you decide use interfaces.

Get familiar with the dependency injection container. It simplifies your life, but the principle of operation of such a container can seem very confusing, especially when you are just starting to master it. At first glance, this opportunity can give some magic.
In this example, we will use the container from Unity, but there are many others to choose from, I will name only the most popular: Castle windsor, Ninject. From a functional point of view, these containers are practically no different. The difference may be noticeable at the level of syntax and style, but, in the end, it all comes down to your personal preferences and development experience (as well as what is prescribed in your company!).

Let’s take a closer look at an example using Unity: I will try to explain what is happening here.

The first thing you need to do is add a link to Unity. Fortunately, there is a Nuget package for this, so right-click on your project in Visual Studio and select Manage Nuget Packages:

Find and install the Unity package, focus on the Unity Container project:

So, we are ready. Change method Main file Program.cs like this:

Program.cs

 static void Main(string[] args)
        {
            var number1 = GetNumber("Enter the first number: > ");
            var number2 = GetNumber("Enter the second number: > ");
            var operation = GetOperator();
            // Следующие три строки необходимо изменить
            var container = new UnityContainer();
            container.RegisterType();
            var calc = container.Resolve();
            var result = GetResult(calc, number1, number2, operation);
            Console.WriteLine($"{number1} {operation} {number2} = {result}");
            Console.Write("Press any key to continue...");
            Console.ReadKey();
        }

Again, only those lines that are marked need to be changed. At the same time, we don’t have to change anything in the Calculator class, in any of the loggers, or in their interface: now they are all implemented at runtime, so if we need a different logger, we just need to change the specific class, registered for ILogger.

The first time you run this code, you may encounter this error:

This is probably one of the quirks with the version of the Unity package that was relevant at the time of this writing. I hope everything goes smoothly for you.
The thing is that when you install Unity, the wrong version of another package is also installed, System.Runtime.CompilerServices.Unsafe. If you get this error, you should return to the Nuget package manager, find this package under the “Installed” tab and update it to the latest stable version:

Now that this project is working, what exactly is it doing? When you first write code in this way, you are tempted to assume that some kind of magic is involved. Now I’ll tell you what we really see here and, I hope, this will be a magic session with exposure, so you won’t beware of working with containers later on.

It all starts with a line var calc = container.Resolve();, therefore, it is from here that I will explain the meaning of this code in the form of a “dialogue of the container with itself”: what it “thinks about” when it sees this instruction.

  1. “I was asked to allow something called Calculator. I know what is it?”
  2. “I see in the current process tree there is a class called Calculator. This is a specific type, which means it has only one implementation. Just create an instance of this class. What do designers look like? ”
  3. “Hmm, there’s only one constructor, and he’s accepting something called ILogger. I know what is it?”
  4. “Found, but it’s the same interface. I was generally told how to resolve it? ”
  5. “Yes, it was reported! The previous line says that whenever I need to allow ILoggerI have to pass an instance of the class NullLogger. ”
  6. “Okay, so there’s NullLogger. He has a non-parameterized constructor. Just create an instance. ”
  7. “I will pass this instance to the class constructor Calculatorand then I will return this instance to var calc. ”

Please note: if u NullLogger If there was a constructor that would request additional types, then the constructor would simply repeat all the steps for them, starting with 3. In principle, it scans all types and tries to automatically resolve all found types to specific instances.

Also note that in paragraph 2, developers do not have to explicitly register specific types. If desired, this can be done, for example, to change the life cycle or pass derived classes, etc. But this is another reason to avoid creating interfaces until they have an obvious need.

That’s all. Nothing mysterious and especially mystical.

Other features

It is worth noting that containers allow you to do some more things that would be rather difficult (but not impossible) to implement on your own. Among these things are life cycle management and the implementation of methods and properties. A discussion of these topics is beyond the scope of this article because it is unlikely that beginners will need such techniques. But I recommend that you, having mastered the topic, carefully read the documentation in order to understand what other possibilities exist.

If you want to experiment with the examples in this article yourself: feel free to clone the repository from Github in which they are posted github.com/GrahamDo/OfferZenDiTutorial.git. There are seven branches, one for each iteration we have examined.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *