Dependency injection principles
October 27, 2020Using dependency inside an app is a nice technique to decouple dependencies and maintain them over time.
But to decouple our code no need for a dependency injection framework! Actually introducing one before decoupling might even have the opposite desired effect...
So today let's have a look to dependency injection principles with no framework and how we can structure our code to ease its use.
Principles
Dependency injection principle is simple: you do dependency injection when giving objects than others rely on either through init or properties. This specific part is also called Inversion of Control: instances are not defined by the class but given to it (but very often Inversion of Control and dependency injection naming are used interchangeably)
Here is an simple dependency injection example where we inject URLSession
to our class MovieRepository
:
class MovieRepository {
let urlSession: URLSession
init(urlSession: URLSession) {
self.urlSession = urlSession
}
}
let instance = MovieRepository(urlSession: URLSession())
On the other hand this code is what we would call hardcoded dependencies:
class MovieRepository {
let urlSession: URLSession
init() {
self.urlSession = URLSession()
}
}
let instance = MovieRepository()
Put it another way dependency injection is merely just that: not hardcoding instances 😳.
To efficiently work a dependency injection framework will actually also rely on another pattern, ServiceLocator.
Opening classes
To be compliant and ready for injection the first thing we have to start with is getting rid of hardcoded dependencies. Depending on your codebase removing them right away might be cumbersome or even error prone. In this case best advice is to take a step smaller and make injection optional:
class MovieRepository {
let urlSession: URLSession
init(urlSession: URLSession = URLSession.shared) {
self.urlSession = urlSession
}
}
let instance = MovieRepository()
Here we provide a default value for urlSession
. It allow us to open our class to injection while still maintaining backward compatibility by providing a default (hardcoded) dependency. Doing so we will be able to migrate little by little our classes without breaking the whole project before moving to a dependency injection framework.
Fixing relationships
Opening our classes for injection is however not always that easy. Let's take this code for instance:
class MovieViewModel {
let repository: Repository
init(urlSession: URLSession, jsonDecoder: JSONDecoder) {
self.repository = Repository(urlSession: urlSession, jsonDecoder: jsonDecoder)
}
}
class Repository {
let network: Network
init(urlSession: URLSession, jsonDecoder: JSONDecoder) {
self.network = Network(urlSession: urlSession, jsonDecoder: jsonDecoder)
}
}
class Network {
init(urlSession: URLSession, jsonDecoder: JSONDecoder) {
self.urlSession = urlSession
self.jsonDecoder = jsonDecoder
}
}
let viewModel = MovieVidewModel(urlSession: URLSession(), jsonDecoder: JSONDecoder())
Let's see what going on here. Each object has its own set of dependencies:
Network
depend onURLSession
andJSONDecoder
Repository
depend onNetwork
- Finally,
MovieViewModel
depend onRepository
Because
MovieViewModel
depend onRepository
it also implicitly depend onRepository
dependencies. These are called transitive dependencies.
If we look to our code we can see that one object dependencies (Network
) is carried over the others (MovieViewModel
, Repository
). As such MovieViewModel
and Repository
will (strongly) depend not only on their own dependencies but also on their transitive ones.
This approach come with multiple downside:
- 🐫 Redundancy: you will pass same object instance at multiple places
- 🏗️ Complex refactoring: multiple classes will be impacted when changing one class dependencies. This is a cascade effect.
- 🔊 Noise: a class like
MovieViewModel
does not useURLSession
norJsonDecoder
yet we still have to pass them at init.
Good luck to use a dependency injection framework on top of that 🤼.
To fix that all we need to do is clean our init
methods and only give direct dependencies to our classes. In other word: MovieViewModel
and Repository
must not reference their transitive dependencies but only their own dependencies. Once we've done that we can then apply default value technique we saw before.
class MovieViewModel {
let repository: Repository
init(repository: Repository) {
self.repository = repository
}
}
class Repository {
let network: Network
init(network: Network) {
self.network = network
}
}
class Network {
init(urlSession: URLSession = .share, jsonDecoder: JSONDecoder = JSONDecoder()) {
self.urlSession = urlSession
self.jsonDecoder = jsonDecoder
}
}
let viewModel = MovieVidewModel(
repository: Repository(
network: Network()
)
)
Our code relationships are now clean and we are now ready to use a dependency injection framework.
Conclusion
Even if you don't intend to make dependency injection in your app a goal, respecting your object relationships/dependencies help reducing coupling and as such code complexity.
It will also increase your code readability by explicitly stating objects relationships. On the other hand it will probably make instance creation a little bit more verbose. That's where dependency injection frameworks might come in handy.