Dependency Injection Container
January 03, 2023In a previous article Dependency Injection principles I explained what is dependency injection π. Let's now have a look on how to manage and use dependencies through a Dependency Container.
Is a Container mandatory?
Let's be clear: you don't need a Depdency Injection Container to benefit from Dependency Injection. However DI Container can be really helpful when dealing with multiple dependencies, especially to manage their lifecycle.
In essence a DI Container is a type knowing how to build an object and all its dependencies. You can write your own or use a library. We'll do both but let's start by doing our own.
If you did not read Dependency Injection principles or are not very familiar with Dependency Injection concepts then read it first! Using DI Container upon π© will just bring you bigger π©π©.
Creating a DI Container
Let's reuse our MovieViewModel
and MovieRepository
from Dependency Injection principles and create a Dependency Container for it.
class DependencyContainer {
func resolveMovieViewModel() -> MovieViewModel {
MovieViewModel(repository: resolveMovieRepository())
}
private func resolveRepository() -> Repository {
Repository(urlSession: .shared, jsonDecoder: JSONDecoder())
}
}
As you can see the code is actually fairly identical to what we had before. The difference being the resolution now happens in a dedicated class rather than relying on parameters default value.
One advantage of this approach is that you cannot forget to resolve a dependency: in order to instantiate a MovieViewModel you know need to explicitly give a MoveRepository.
Lifecycle
One neat thing about Dependency Container is how you can configure objects lifecycle.
Here for instance we can quickly swipe our Repository from a created-at-every-call to a shared one (a singleton per se).
class DependencyContainer {
private lazy var repository = Repository(urlSession: .shared, jsonDecoder: JSONDecoder())
func resolveMovieViewModel() -> MovieViewModel {
MovieViewModel(repository: resolveRepository())
}
/// now we'll always get the same repository instance
private func resolveRepository() -> Repository {
repository
}
}
Swinject
Swinject π is one of the most popular Dependency injection framework for Swift.
Our previous container could be rewritten as follow:
import Swinject
class AppAssembly: Assembly {
func assemble(container: Container) {
container.register(MovieViewModel.self) { resolver in
MovieViewModel(repository: resolver.resolve(Repository.self)!)
}
container.register(Repository.self) {
Repository(urlSession: .shared, jsonDecoder: JSONDecoder())
}
.inObjectScope(.container) // singleton
}
}
As you can see the code is fairly the same.
Using the container
The most important thing to remember is that Dependency Container manages dependencies lifecycle βΎοΈ. Therefore it's very important to have only one container instance.
One approach to ensure this uniqueness is by instantiating it in AppDelegate
.
import Swinject
class AppDelegate {
private let depencenyResolver = Assembler([AppAssembly()]).resolver
}
How to use it then depends on whether or not you have a router. Let's start with the least probable case π: you have a router inside your app.
Routed app
Router pattern π£ is great because it's a common path where you main app logic always goes through.
Magellan is one such example: it has a Router
closure called every time we display a new screen. For people using SwiftUI, NavigationStack
and NavigationPath
can be used to build such a component.
In this approach our router is in charge of resolving dependencies:
import Magellan
class AppDelegate {
private let dependencyResolver = Assembler([AppAssembly()]).resolver
private var navigation: Navigation!
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) -> Bool {
setupNavigation()
}
func setupNavigation() {
navigation = Navigation(root: window.rootViewController!) { [dependencyResolver] route, _ in
switch route {
case .movies:
let viewController = StoryboardScene.Main.movies.instantiate()
viewController.viewModel = MoviesViewModel(
repository: dependencyResolver.resolve(MovieRepository.self)!
)
return Route(viewController).present(PageSheetPresentation())
}
}
}
If you don't have a Router don't worry though! You can still use a dependency container.
The less good but more usual case
With no router you need to access your Dependency Container from every root controllers (or views).
In SwiftUI you can rely on @EnvironmentObject
to access and use you container. However be sure to not over use it and access it only from root views (basically your smart views if you've been following Smart/Dumb approach).
import Swinject
struct MyScreen: View {
@EnvironmentObject dependencyResolver: Resolver
var body: some View {
NavigationLink() {
....
}
}
@ViewBuilder
private var moviesScreen: some View {
let viewModel = MoviesViewModel(repository: dependencyResolver.resolve(MovieRepository.self)!
MovieScreen(viewModel: viewModel)) {
...
}
}
}
On UIKit you'll have to set the instance on each root view controller.
import Swinject
class RootController: UIViewController {
var dependencyResolver: Resolver!
func showMovies() {
let viewController = StoryboardScene.Main.movies.instantiate()
viewController.dependencyResolver = dependencyResolver
viewController.viewModel = MoviesViewModel(
repository: dependencyResolver.resolve(MovieRepository.self)!
)
pushViewController(viewController, animated: true)
}
}
This way you'll be able to access your container and resolve your dependencies.
This is how you can either build your own DI Container or use a third-party library. Which approach to use is mostly a matter of taste although I would advise for libraries as it can greatly simplify object lifecycle.
However libraries might come with a very big tradeoff you probably didn't notice at first glance: compile-time safety π·! We'll dive πββοΈ on this in a future article.