It all depends on what you're trying to achieve with the dependency injection.
From what I've seen, the most current usage of a DI framework is to be able to swap real implementation with stubs/mocks when unit testing the code:
1. When DI is used to replace dependencies by stubs/mocks in unit tests
If you don't want to use a DI framework but you want to get the benefit of DI to mock the dependencies of a class under test, you can use default values. Instead of:
class ImageUploader
{
public ImageUploader(IImageProcessor processor) { ... }
}
you'll have:
class ImageUploader
{
public ImageUploader(IImageProcessor processor = null)
{
this.processor = processor || new ImageProcessor();
}
}
This way, in application code, you don't have to pass anything to a constructor: default dependencies will be used. When doing unit tests, however, you'll use the constructor to inject the stubs and mocks you want.
Obviously, this works only in situations where there is a default implementation for an interface. If there are no defaults (such as the case where the app has to make a choice between PostgreSqlProvider
and OracleProvider
based on the application configuration), then you still have to pass an object to a constructor.
Sometimes, you may encounter situations where you have the defaults nearly everywhere in the chain, but then, somewhere, there is a class which needs a custom dependency. For instance, ImageProcessor
may need a storage mechanism, which is either InMemoryStorage
or FileStorage
. In this case:
You can pass the dependency through the chain, so it would belong to the controller, in your example, to do the choice. The drawback is that you end up with the chains as the one you used as an illustration in your question.
You may use a singleton which will store the instance of a storage mechanism, freeing the chain and ensuring that only the class which needs it (in our case, ImageProcessor
) would access it. I would advise against it, since it would make it difficult to impossible to test ImageProcessor
.
Or you have an object such as ApplicationConfiguration
which feeds the classes which need a dependency with the corresponding instance. In your case, it would mean having:
interface IApplicationConfiguration
{
IStorage getImageStorage();
}
class ApplicationConfiguration: IApplicationConfiguration
{
IStorage getImageStorage()
{
... // Based on a config file, return either `InMemoryStorage` or
//`FileStorage`.
}
}
class ImageProcessor: IImageProcessor
{
... // All logic which should be unit tested goes here.
}
class DefaultImageProcessor: ImageProcessor
{
// Contains no business logic; simply uses
// `ApplicationConfiguration.getImageStorage` to retrieve the instance
// of the storage.
}
This third solution gives you the benefit of being able to unit test all of your code, with the exception of DefaultImageProcessor
, which has its role limited to passing the correct dependency to the base ImageProcessor
class.
Note that while it works for the purpose of being able to swap dependencies between implementation and stubs/mocks, this is pretty much the only benefit of this approach, and all other benefits of dependency injection are lost.
Drawbacks
One of the drawbacks you need to consider is that at the level of packages/assemblies, it links the classes with their dependencies in the exact same order it would do if no DI was used.
For instance, with DI, I can create an assembly/package which contains ImageService
and IImageUploader
. This assembly/package knows nothing about ImageUploader
. The benefit of this—and this is one of the most important benefits of dependency injection—is that I can modify the assembly/package containing one of the implementations of IImageUploader
without affecting in any way the assembly/package which contains ImageService
. I don't need to rebuild it. I don't need to publish a new version of a package to the package repository. This makes perfect sense, because ImageService
was not changed.
With default parameters in the constructor, this benefit is wasted. I have no other choices as having the assembly/package-level dependency from the package containing ImageService
to the one which contains ImageUploader
. Now, when I change something in the implementation of ImageUploader
, the package containing ImageService
has to be recompiled as well.
In other words, if the default parameters approach looks like DI, it's not a IoC (and therefore not a DI, since DI is just a form of IoC), since there is no inversion.
Does it matter?
If your only goal is to be able to swap dependencies during tests, it shouldn't matter much.
If your goal is to have a clean architecture, then default parameters won't work for you.
2. When DI is used to do the actual IoC
In a case of IoC, you'll end up at some level passing instances to constructors anyway. The only way to avoid writing the code yourself is to delegate the job to a library—this is exactly what DI framework does. Its goal is not to make the relationships simpler or anything like that. The goal of a DI framework is to hide the code related to DI from you (usually by using Reflection, which causes its own problems).
Obviously, you're free to use different approaches at different levels. For instance, you may decide that it makes perfect sense to have a strong relation between ImageUploader
and ImageProcessor
; they would find themselves side by side, within the same assembly/package, and in this case, using a default parameter in a constructor would be a wise choice.
At the same time, you may want ImageProcessor
to rely on a storage mechanism which depends on the configuration of the application. In this case, the solution with DefaultImageProcessor
I described above may work.
And then, you may have a case of a log class that you'll like to access from any class within the application. Here, a static factory may work well, as soon as you're able to replace the real loggers by stubs for unit testing.
Finally, you may find that ImageService
is completely independent from ImageUploader
and ImageProcessor
. You'll put it in a separate package, and use IoC. The ImageController
will then pass an instance of IImageUploader
when initializing the service.