We are preparing to release the second edition of the legendary book by Mark Siman " Dependency Injection on the .NET Platform ".
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 most likely connected only by a contract .
In C #, this means that your service implementations must meet the requirements of the interface, and when creating consumers for your services, you must target the interface , not the implementation, and require the implementation to be provided or implemented to you.so 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 intent is simply passed 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 ours
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 that log messages in very different ways, but both do
ILogger
, which means that either one can be used wherever an instance is needed
ILogger
. Next, you can create an instance
DirectoryWatcher
and tell it 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 (for example, 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
ILogger
, and it will return us the instance
TextFileLogger
, or whatever instance we initialized the container with.
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 of 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.