Why DI containers fail with “complex” object graphs

In object oriented programming, SOLID is a set of principles that allows us to create flexible software that is easy to maintain and extend in the future. If we use such principles, we end up with a lot of small classes in our code base. Each of these classes will have only a single responsibility, but can collaborate with other classes.

We use Dependency Injection to create loosely coupled classes. Classes do not depend on other classes directly, instead they depend on abstractions. A class declares that it depends on specific abstractions, and a third party will inject concrete implementations of such abstractions when an object of such class is created.

This third party is usually some code that lives in the application entry point and is called the Composition Root. This code will create a graph of objects that formulate the application.

Two ways of creating such object graph are:

  1. Using a Dependency Injection (DI) container (also called Inversion of Control (IOC) container).
  2. Pure DI.

In this article I talk about a problem of using DI Containers and show how using Pure DI might be a better solution.

Let’s start by an example of creating an object graph using a DI container. Consider the following UML class diagram:

URL diagram

Here, the OrderProcessor class depends on both ICurrencyConverter and IEmailSender interfaces.

The EmailSender class requires an smtp server address to function. Such information is provided to the class via its constructor. Similarly the CurrencyConverter class requires a currency conversion web service URL to function. Such URL is provided to the class via the constructor.

The OrderProcessor class constructor requires an IEmailSender and an ICurrencyConverter.

We can use a DI container to map each interface with the corresponding class that implements it. After such mapping, we can use the container to create as many objects as we want. The following code demonstrates this using Unity DI Container (which is a DI container created by Microsoft):

UnityContainer container = new UnityContainer();
container.RegisterType<IEmailSender, EmailSender>();
container.RegisterType<ICurrencyConverter, CurrencyConverter>();
container.RegisterType<IOrderProcessor, OrderProcessor>();
var processor = container.Resolve(
    new ParameterOverride("url", "http://service.currency.lab"),
    new ParameterOverride("smtp_server", "smtp.lab.lab"));

Here we map each interface to the class that implements it. After doing that we ask the container to build us an order processor. We provide any required settings such as the currency conversion web service URL and the email server address. Other than that, the container knows how to construct the object graph using something called auto-wiring.

The container starts by looking in the registered maps for the IOrderProcessor interface. It will find that it is mapped to the OrderProcessor class. Looking at such class, the container finds that the constructor of such class requires an IEmailSender and an ICurrencyConverter. Now the container will try to resolve such dependencies, it looks into its registration map. It will find that the IEmailSender interface is mapped to EmailSender and the ICurrencyConverter interface is mapped to the CurrencyConverter class. The container will try to create an EmailSender class. Looking at its constructor, it will see that it requires a string “url”. Since we have provided such “url” when we invoked the Resolve method, the container will be able to construct the EmailSender. The same thing goes with the CurrencyConverter class. After creating these dependencies, the container is now ready to create the OrderProcessor class. This same process will work regardless of the size of the object graph or its depth.

In pure DI, we create the object graph manually. Here is how we would create the same object graph using pure DI:

var processor = new OrderProcessor(
    new EmailSender("smtp.lab.lab"),
    new CurrencyConverter("http://service.currency.lab"));

Here comes the benefit of using the DI container: Imagine that you have a bigger object graph. Let’s say that other than the email sender service and the currency conversion service, you have an order storage service (or an order repository), a SMS sending service, a credit card processing service, an order queuing service, a customer loyalty management service, a logging service, security related services and many other services. Imagine that there are 20 such services and some of them depend on each other. And let’s say that you have some 20 application-level classes (like ASP.Net controllers) that depend on these services to work. Constructing such classes manually requires a lot of code and thus costs more to maintain.

For the sake of the discussion I will define the following:

  1. Simple object graph: an object graph of any size and any depth that has the following two attributes:
    a) For any interface (or abstraction) at most one class that implements such interface is used in the object graph.
    b) For any class, at most one instance is used in the object graph (or multiple instances with the exact same construction parameter arguments). This single instance can be used multiple times in the graph
  2. Complex object graph: Any other object graph.

The previous object graph is an example of a simple object graph.

Please note that I am using the terms “simple” and “complex” here with a special meaning. I chose these terms because I could not come up with better ones. In different contexts, “complex object graph” means something else.

My argument in this article is that once the object graph starts to get “complex”, pure DI quickly becomes a better alternative than using a DI container.

There are other reasons why pure DI might be a better alternative, but in this article I will only speak about graph complexity.

Please note that here we can have a rough measure of complexity depending on how many objects violate the two previous rules. Some graphs are more complex than others.

Here are some reasons why we can have a complex object graph:

For attribute (a):

  1. We sometimes use the Decorator pattern to add functionality to some object. For example, we might create a decorator for the IEmailSender class to encrypt the email message before we send it.
  2. We might abstract some concept behind an interface and have two implementations that are going to be used simultaneously in our application. For example, consider an IDocumentSource interface for an application that processes documents. We might want the application to pull documents from an FTP site, and in the same time we also want it to pull documents form a database. We might have an FTPDocumentSource class, and a DatabaseDocumentSource class. And then we might create a façade to obtain documents from both sources.

For attribute (b):

Using the same IDocumentSource example, we might have different FTP servers that we want to pull documents from. So we create two or more instances of the FTPDocumentSource class. Using an ftp server address parameter at the constructor, we will be able to point one object to ftp server 1 and the other object to ftp server 2.

To make things complex, imagine that there is some configuration file or database that contains the list of ftp servers that we want to pull the documents from (such configuration file/database can be updated by the application administrator). We will need to create FTPDocumentSource objects at runtime when our application starts. Also, based on such configuration, we might decide to decorate some but not all of our objects. For example, imagine the following configuration table in the database (the same can be done inside a configuration file):

FTP Server Document types to include
ftp1.server1.lab ALL
ftp2.server2.lab pdf;docx
ftp3.server3.lab pdf

So, we have our FTPDocumentSource class, and also we have a decorator to filter documents based on document type. Such decorator might be named DocumentSourceFilteringDecorator. Such decorator takes in an IDocumentSource and a string filter in its constructor, and when it is invoked to pull documents, it would filter the documents based on the filter string.

In the example configuration in the database, the first FTP source does not require applying the decorator. While the other two sources do require such decorator.

We also need another IDocumentSource implementation to make multiple document sources look like a single document source for the document source consumer. Such implementation can be called AggregatedDocumentSource.

Here is how this part of the object graph would look like:

Object Graph

Here is a UML class diagram for the involved types:

URL diagram

Here is how such object graph is configured and created using Unity DI container:

//Assume this comes from configuration database or file
SourceSettings[] settings =
{
    new SourceSettings { FTPServerAddress = "ftp1.server1.lab" , Filter= "ALL"},
    new SourceSettings { FTPServerAddress = "ftp2.server2.lab" , Filter= "pdf;docx"},
    new SourceSettings { FTPServerAddress = "ftp3.server3.lab" , Filter= "pdf"}
};

UnityContainer container = new UnityContainer();

List<string> names = new List<string>();

for(int i = 0 ; i < settings.Length ; i++)
{

    string ftp_document_source_name = "ftp_document_source" + i;
    string filtering_document_source_name = "filtering_document_source" + i;

    container.RegisterType<IDocumentSource, FTPDocumentSource>(
        ftp_document_source_name,
        new InjectionConstructor(settings[i].FTPServerAddress));

    if (settings[i].Filter != "ALL")
    {
        container.RegisterType<IDocumentSource, DocumentSourceFilteringDecorator>(
            filtering_document_source_name,
            new InjectionConstructor(
                new ResolvedParameter<IDocumentSource>(ftp_document_source_name),
                settings[i].Filter));

        names.Add(filtering_document_source_name);
    }
    else
    {
        names.Add(ftp_document_source_name);
    }
}

object[] document_sources_resolved_parameters =
    names.Select(x => new ResolvedParameter<IDocumentSource>(x)).Cast<object>().ToArray();


container.RegisterType<IDocumentSource, AggregatedDocumentSource>(
    new InjectionConstructor(
        new ResolvedArrayParameter<IDocumentSource>(document_sources_resolved_parameters)));

var document_source = container.Resolve<IDocumentSource>();

 

You can see from this example how complex it has become to create the object graph using a DI container.

Since we have multiple registrations for the same interface, we use names to distinguish between different registrations.

In the above code, we loop through the list of ftp servers that we got from the configuration database or file, and for each one of them we decide whether we want to have a filtering decorator or not based on the corresponding filter.

  1. If we decide that we do want to filter such source, we make 2 registrations, one for the FTP document source, and the other one for the filtering decorator. We link the FTP document source and its corresponding filtering decorator using the name of the FTP document source registration. We store the name of the filtering decorator registration in the names list because we will need it when we create the AggregatedDocumentSource.
  2. If we decide not to filter the document source, we simply make a single registration for the FTP document source and we give it a name. Again, we store the name of the registration.

Notice how we use the index “i” to create unique names.

In the end, we need to register the AggregatedDocumentSource class. Such class accepts an array of IDocuemntSource objects in its constructor. We use the names we collected in the loop to point this registration to the document sources it needs to link to.

As you can see, we no longer use the auto-wiring feature. It’s like we are manually constructing the graph, but with more verbosity.

Now, let’s take a look on how we can use pure DI to construct such object graph.

SourceSettings[] settings =
{
    new SourceSettings { FTPServerAddress = "ftp1.server1.lab" , Filter= "ALL"},
    new SourceSettings { FTPServerAddress = "ftp2.server2.lab" , Filter= "pdf;docx"},
    new SourceSettings { FTPServerAddress = "ftp3.server3.lab" , Filter= "pdf"}
};


List<IDocumentSource> sources = new List<IDocumentSource>();

foreach (var source_settings in settings)
{
    IDocumentSource source = new FTPDocumentSource(source_settings.FTPServerAddress);

    if (source_settings.Filter != "ALL")
        source = new DocumentSourceFilteringDecorator(source, source_settings.Filter);

    sources.Add(source);
}

var document_source = new AggregatedDocumentSource(sources.ToArray());

 

Here, it is very clear that using pure DI in this case produces much more readable and maintainable code.

When we start having complex graphs, we lose the ability to auto-wire the graph using the auto-wiring feature provided by DI containers. Most of the registrations will have names (or other DI container features with similar consequences), and we would be manually-wiring the objects ourselves, but with more code and less compile-time validation support than pure DI.

Named registration isn’t required just for the types that violate the simple graph attributes. Once you start having such named registrations, some objects that depend on types that were registered with names will also require names for registration. Take a look at this example:

UML diagram

Here we have a DocumentsProcessor class that takes in an IDocumentSource in its constructor, and will process documents from the source when we call its Process method.

Let’s say that we want to create a DocumentProcessor for each document source, i.e., we want to have the following graph (all 3 DocumentProcessor objects will be used in a single graph):

Object graph

In this case, even if you don’t have multiple implementations of IDocumentProcessor, you would still need to name each one of the 3 DocumentProcessor registrations, because each one of them depends on a different configuration of IDocumentSource.

Summary:

For simple object graphs, DI containers can be a good choice. Although in my experience, graphs tend to become complex very fast in a project lifetime. If you use DI containers, once the graph becomes complex you would either have to switch to pure DI or live with a lot of named registrations.

For complex graphs, pure DI is much better.

Leave a Reply

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