Protocol composition for dependency injection
- Transfer
I like to use composition and dependency injection, but when each entity begins to be injected with several dependencies, a certain heap is obtained.
The project is growing and you have to inject more and more dependencies into objects, refactor methods many times, Xcode doesn’t really help with this, as we know.
But there is a more manageable way.
This article will be useful to those who have just started using DI or do not use it at all, and are not yet particularly familiar with IoC. The approach applies to other languages, but because the author writes in Swift, all the examples will be on it (approx. Per.)
Problem
Suppose there is an object that suddenly needs a provider ImageProvider
, write something like this:
class FooCoordinator {
let imageProvider: ImageProvider
init(..., imageProvider: ImageProvider)
///...
}
Quite simple and convenient + this will allow you to replace the provider in the tests.
As the code base grows, more and more dependencies appear, each of which forces:
- refactor the place where it is used
- add new variable
- write some boilerplate
for example, after several months the object may appear 3 dependencies:
class FooCoordinator {
let imageProvider: ImageProvider
let articleProvider: ArticleProvider
let persistanceProvider: PersistanceProvider
init(..., imageProvider: ImageProvider, articleProvider: ArticleProvider, persistanceProvider: PersistanceProvider) {
self.imageProvider = imageProvider
self.articleProvider = articleProvider
self.persistanceProvider = persistanceProvider
///...
}
///...
}
Since there is usually more than one class in a project, the same pattern is repeated many times.
And we must not forget that you need somewhere to store links to all these dependencies, for example, in AppController
or Flow Coordinator
.
This approach will inevitably lead to pain. Pain can motivate the use of not quite the right decisions, for example Singleton.
But we need simple and easy code support, with all the benefits of code injection.
Alternative
We can use the composition of protocols (interfaces) to increase the readability and quality of service of the code.
Let's describe the base protocol container for any dependency we have:
protocol Has{Dependency} {
var {dependency}: {Dependency} { get }
}
Change {Dependency} to the name of the object
For example, ImageProvider
it would look like this:
protocol HasImageProvider {
var imageProvider: ImageProvider { get }
}
Swift allows you to compose protocols using the operator &
, which means that our entities can now contain just one dependency store:
class FooCoordinator {
typealias Dependencies = HasImageProvider & HasArticleProvider
let dependencies: Dependencies
init(..., dependencies: Dependencies)
}
Now in AppController
or Flow Coordinator
(Or what is used there to create entities) you can hide all the dependencies under one container in the form of a structure:
struct AppDependency: HasImageProvider, HasArticleProvider, HasPersistanceProvider {
let imageProvider: ImageProvider
let articleProvider: ArticlesProvider
let persistanceProvider: PersistenceProvider
}
And all the application dependencies will now be stored in a container that does not have any logic or anything magical, just a structure.
This approach improves readability, all dependencies are stored together, but more importantly, the configuration code is always the same, regardless of which dependencies your object needs:
class FlowCoordinator {
let dependencies: AppDependency
func configureViewController(vc: ViewController) {
vc.dependencies = dependencies
}
}
Each object describes only those dependencies that it really needs, and he will receive only them.
For example, if ours is FooCoordinator
needed ImageProvider
, then it will roll through the AppDependency
structure, and type checking in Swift will provide access only toImageProvider
If in the future you need more dependencies, for example PersistanceProvider
, then you just need to add it to ours typealias
:
class FooCoordinator {
typealias Dependencies = HasImageProvider & HasArticleProvider & HasPersistanceProvider
}
That's all.
This approach has several advantages:
- Dependencies are clearly defined and always consistent, in any object, throughout the project
- When the dependencies of an object change, you only need to change
typealias
- Neither the initializer nor the configuration function needs to be touched.
- Any of the objects, thanks to the type checking system in Swift, gets only those dependencies that it needs.