Dependency injection for beginners

Hello, Habr!

We are preparing to release the second edition of the legendary book by Mark Siman “.NET Dependency Injection“.

Even in such a voluminous book it is hardly possible to fully cover such a topic. But we offer you an abbreviated translation of a very accessible article that outlines the essence of dependency injection in simple language – with examples in C #.

The purpose of this article is to explain the concept of dependency injection and show how it is programmed in a given project. From Wikipedia:

Dependency injection is a design pattern that separates behavior from dependency resolution. Thus, it is possible to detach components that are highly dependent on each other.

Dependency Injection (or DI) allows you to provide implementations and services to other classes for consumption; the code remains very loosely coupled. The main point in this case is this: in place of implementations, you can easily substitute other implementations, and at the same time you have to change a minimum of code, since the implementation and the consumer are connected, most likely, only contract

In C #, this means that your service implementations must conform to the interface requirements, and when creating consumers for your services, you must target interfacerather than implementation, and require that realization you were given or was introducedso that you don’t have to create the instances yourself. With this approach, you don’t have to worry at the class level about how dependencies are created and where they come from; in this case, only the contract is important.

Dependency injection by example

Let’s look at an example where DI can come in handy. First, let’s create an interface (contract) that will allow us to perform some task, for example, log a message:

public interface ILogger {  
  void LogMessage(string message); 
}

Please note: this interface does not describe anywhere how a message is logged and where it is logged; here the intention is simply to write the string to some repository. Next, let’s create an entity that uses this interface. Let’s say we create a class that keeps track of a specific directory on disk and, as soon as a change is made to the directory, logs the corresponding message:

public class DirectoryWatcher {  
 private ILogger _logger;
 private FileSystemWatcher _watcher;

 public DirectoryWatcher(ILogger logger) {
  _logger = logger;
  _watcher = new FileSystemWatcher(@ "C:Temp");
  _watcher.Changed += new FileSystemEventHandler(Directory_Changed);
 }

 void Directory_Changed(object sender, FileSystemEventArgs e) {
  _logger.LogMessage(e.FullPath + " was changed");
 }
}

In this case, the most important thing to note is that we are provided with the constructor we need, which implements ILogger… But, again, note: we do not care where the log goes, or how it is created. We can just program with the interface in mind and not think about anything else.

Thus, to create an instance of our DirectoryWatcher, we also need a ready-made implementation ILogger… Let’s go ahead and create an instance that logs messages to a text file:

public class TextFileLogger: ILogger {  
 public void LogMessage(string message) {
  using(FileStream stream = new FileStream("log.txt", FileMode.Append)) {
   StreamWriter writer = new StreamWriter(stream);
   writer.WriteLine(message);
   writer.Flush();
  }
 }
}

Let’s create another one that writes messages to the Windows event log:

public class EventFileLogger: ILogger {  
 private string _sourceName;

 public EventFileLogger(string sourceName) {
  _sourceName = sourceName;
 }

 public void LogMessage(string message) {
  if (!EventLog.SourceExists(_sourceName)) {
   EventLog.CreateEventSource(_sourceName, "Application");
  }
  EventLog.WriteEntry(_sourceName, message);
 }
}

We now have two separate implementations, logging messages in very different ways, but both of them implement ILoggerand that means any of them can be used wherever an instance is required ILogger… Then you can create an instance DirectoryWatcher and tell him to use one of our loggers:

ILogger logger = new TextFileLogger();  
DirectoryWatcher watcher = new DirectoryWatcher(logger);

Or, simply by changing the right side of the first line, we can use a different implementation:

ILogger logger = new EventFileLogger();  
DirectoryWatcher watcher = new DirectoryWatcher(logger);

All this happens without any changes to the DirectoryWatcher implementation, and this is the most important thing. We are injecting our logger implementation into the consumer so that the consumer doesn’t have to create an instance on their own. The example shown is trivial, but imagine what it would be like to use such techniques in a large-scale project where you have multiple dependencies, and there are many times more consumers using them. And then suddenly there is a request to change the method that logs messages (say, now messages should be logged to the SQL server for audit purposes). If you do not use dependency injection in one form or another, then you will have to carefully review the code and make changes wherever the logger is actually created and then used. On a large project, such work can be cumbersome and error-prone. With DI, you just change the dependency in one place, and the rest of the application will actually absorb the changes and immediately start using the new logging method.

In essence, it solves the classic software problem of heavy dependency, and DI allows you to create loosely coupled code that is extremely flexible and easy to modify.

Dependency injection containers

Many DI injection frameworks that you can simply download and use go a step further and use a container for dependency injection. In essence, it is a class that stores type mappings and returns a registered implementation for a given type. In our simple example, we will be able to query the container for an instance ILoggerand it will return us an instance TextFileLogger, or any other instance with which we initialized the container.

In this case, we have the advantage that we can register all type mappings in one place, usually where the application launch event occurs, and this will allow us to quickly and clearly see what dependencies we have in the system. In addition, in many professional frameworks, you can configure the lifetime of such objects, either by creating fresh instances with each new request, or reusing one instance in multiple calls.

The container is usually created in such a way that we can access the ‘resolver’ (the kind of entity that allows us to request instances) from anywhere in the project.
Finally, professional frameworks usually support the phenomenon subdependencies – in this case, the dependency itself has one or more dependencies on other types, also known to the container. In this case, the resolver can fulfill those dependencies as well, giving you back a complete chain of correctly created dependencies that correspond to your type mappings.

Let’s create a very simple DI container ourselves to see how it all works. Such an implementation does not support nested dependencies, but it allows you to map an interface to an implementation, and later request this implementation itself:

public class SimpleDIContainer {  
 Dictionary < Type, object > _map;
 public SimpleDIContainer() {
   _map = new Dictionary < Type, object > ();
  } 

/// <summary> 
/// отображает тип интерфейса на реализацию этого интерфейса, с опционально присутствующими аргументами. 
/// </summary> 
/// <typeparam name="TIn">The interface type</typeparam> 
/// <typeparam name="TOut">The implementation type</typeparam> 
/// <param name="args">Optional arguments for the creation of the implementation type.</param> 
 public void Map <TIn, TOut> (params object[] args) {
   if (!_map.ContainsKey(typeof(TIn))) {
    object instance = Activator.CreateInstance(typeof(TOut), args);
    _map[typeof(TIn)] = instance;
   }
  } 

/// <summary> 
/// получает сервис, реализующий T 
/// </summary> 
/// <typeparam name="T">The interface type</typeparam>
 public T GetService<T> () where T: class {
  if (_map.ContainsKey(typeof(T))) return _map[typeof(T)] as T;
  else throw new ApplicationException("The type " + typeof(T).FullName + " is not registered in the container");
 }
}

Next, we can write a small program that creates a container, displays the types, and then requests a service. Again, a simple and compact example, but imagine what it would look like in a much larger application:

public class SimpleDIContainer {  
 Dictionary <Type, object> _map;
 public SimpleDIContainer() {
   _map = new Dictionary < Type, object > ();
  } 

 /// <summary> 
 /// отображает тип интерфейса на реализацию этого интерфейса, с опционально присутствующими аргументами. 
/// </summary> 
/// <typeparam name="TIn">The interface type</typeparam> 
/// <typeparam name="TOut">The implementation type</typeparam> 
/// <param name="args">Optional arguments for the creation of the implementation type.</param> 
public void Map <TIn, TOut> (params object[] args) {  
   if (!_map.ContainsKey(typeof(TIn))) {
    object instance = Activator.CreateInstance(typeof(TOut), args);
    _map[typeof(TIn)] = instance;
   }
  } 

/// <summary> 
/// получает сервис, реализующий T 
/// </summary> 
/// <typeparam name="T">The interface type</typeparam>
 public T GetService <T> () where T: class {
  if (_map.ContainsKey(typeof(T))) return _map[typeof(T)] as T;
  else throw new ApplicationException("The type " + typeof(T).FullName + " is not registered in the container");
 }
}

I recommend sticking to this pattern when adding new dependencies to your project. As your project grows in size, you’ll see for yourself how easy it is to manage loosely coupled components. Considerable flexibility is gained, and the project itself is ultimately much easier to maintain, modify and adapt to new conditions.

Similar Posts

Leave a Reply

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