In Part 1 of our article Dependency Injection for iOS Applications we introduced Dependency Injection (DI) and looked into Init based DI. In the second part we will be discussing another common type, the Container based DI.
Container based Dependency Injection
The container based DI approach is different from the init based approach. In DI we don’t instantiate the dependencies inside the class that uses them, but we provide them from the outside.
In container based DI, that “outside” is clearly defined. We introduce the concept of a dependency injection container. A dependency injection container is an object that has two methods:
- register: used for “registering” dependencies. We teach the DI container how to create the dependency.
- resolve: used for getting a dependency when we need to use it.
This means that all dependencies are instantiated and obtained in a single place: the DI container. This also means another thing: we can have different DI containers depending on the environment we are. For instance, we could have a DI container for production, and another one for development. Or a DI container with dependencies especifically designed for unit testing or UI testing.
Swinject
Swinject is one of the most widely used container based DI frameworks for Swift applications. As explained before, the most important concept in these kind of frameworks is the Container, and how we can register and resolve dependencies using it.
So, after installing Swinject using the installation instructions, we can start by creating a Container:
let container = Container()
This Container can be used for registering our dependencies:
let container = Container()
container.register(FriendsRepository.self /* The protocol type */) { _ in
// We return the concrete implementation
return DefaultFriendsRepository()
}
container.register(ShareService.self) { _ in
return DefaultShareService()
}
container.register(NotificationService.self) { _ in
return DefaultNotificationService()
}
The ignored parameter in the register factory closure is a Resolver object that can be used to build dependencies based on other dependencies (called a dependency graph). For this simple use case, this won’t be needed, but it’s a very important feature for more advanced use cases.
Now, that’s all we need. We have “taught” our Container how to build our dependencies. Before starting resolving dependencies in the ShareViewModel, we can create some handy code around this.
We need to store our Container somewhere. I have seen guides explaining that the AppDelegate is a good place to store it. I prefer creating an Injection singleton class:
final class Injection {
static let shared = Injection()
var container: Container {
get {
if _container == nil {
_container = buildContainer()
}
return _container!
}
set {
_container = newValue
}
}
private var _container: Container?
private func buildContainer() -> Container {
let container = Container()
container.register(FriendsRepository.self) { _ in
return DefaultFriendsRepository()
}
container.register(ShareService.self) { _ in
return DefaultShareService()
}
container.register(NotificationService.self) { _ in
return DefaultNotificationService()
}
return container
}
}
Using this Injection class, we can resolve our dependencies this way:
let friendsRepository = Injection.shared.container.resolve(FriendsRepository.self)!
Still not very convenient… but we can do something else. Using Swift property wrappers, we can define an @Injected property wrapper to simplify this process:
@propertyWrapper struct Injected<Dependency> {
let wrappedValue: Dependency
init() {
self.wrappedValue =
Injection.shared.container.resolve(Dependency.self)!
}
}
The wrappedValue here defines the actual property value.
And this is much simpler to use:
// It needs to be a `var`
@Injected var friendsRepository: FriendsRepository
Now, let’s refactor our ShareViewModel to use this new Container:
class ShareViewModel: ObservableObject {
@Published var friends: [User] = []
private let post: Post
// Dependencies aren\'t instantiated in this class
@Injected private var friendsRepository: FriendsRepository
@Injected private var shareService: ShareService
@Injected private var notificationService: NotificationService
// The init is not used to inject dependencies now
init(post: Post) {
self.post = post
}
func loadFriends() async {
do {
self.friends = try await friendsRepository.getFriends()
} catch {
self.friends = []
}
}
func share(with friend: User) async {
do {
try await shareService.share(post, with: friend)
notificationService.showSuccess(message: \"Post shared with \\\\(friend.username)\")
} catch {
notificationService.showError(message: \"An error ocurred\")
}
}
}
Unit testing using container based UI
This Injection class has another useful trait: it doesn’t instantiate the container variable until it’s first used, so it’s possible to define a custom Container before we use its getter. This will be very important for unit testing.
So, using our new Injection class, and its Container, we can unit test the ShareViewModel. First, let’s answer a question you might be asking ourselves: where should we put the mock Container configuration in the tests? A simple answer I found for this, is to create a BaseTestCase class, which contains the Container initialization:
import Foundation
import XCTest
import Swinject
@testable import SwinjectExample
class BaseTestCase: XCTestCase {
override func setUp() {
super.setUp()
Injection.shared.container = buildMockContainer()
}
func injectedMock<Dependency, Mock>(for dependencyType: Dependency.Type) -> Mock {
return Injection.shared.container.resolve(Dependency.self) as! Mock
}
private func buildMockContainer() -> Container {
let container = Container()
container
.register(FriendsRepository.self) { _ in
return MockFriendsRepository()
}
.inObjectScope(.container)
container
.register(ShareService.self) { _ in
return MockShareService()
}
.inObjectScope(.container)
container
.register(NotificationService.self) { _ in
return MockNotificationService()
}
.inObjectScope(.container)
return container
}
}
A lot of things are happening in this example. Let’s analyze them.
First, inside the buildMockContainer we are creating and returning a Swinject Container. This Container has mocked versions for all the dependencies we have. The inObjectScope(.container) is important because, if we don’t use it, the dependency will be recreated every time we want to resolve it. For real dependencies that’s not a big deal. However, for our mocks it is very important, because they hold state, and those variables will be cleared every time we resolve the dependency. What’s more, the ShareViewModel will have different instances of the dependencies than those the ShareViewModelTests will have.
Note the Container is regenerated on each setUp function call, so mocks won’t share values across test cases.
Finally, the injectedMock function is a helper function to access mocks in unit tests.
Now, this is how our ShareViewModelTests should be written to use all of this:
class ShareViewModelTests: BaseTestCase {
enum TestError: Error {
case testCase
}
private let post = Post(id: 1234)
// We use `injectedMock` for getting references to the mocks.
// Not these are lazy var, because otherwise we would be accessing
// an instance member (injectedMock) before the class finished instantiating.
private lazy var friendsRepository: MockFriendsRepository = injectedMock(for: FriendsRepository.self)
private lazy var shareService: MockShareService = injectedMock(for: ShareService.self)
private lazy var notificationService: MockNotificationService = injectedMock(for: NotificationService.self)
private var viewModel: ShareViewModel!
override func setUp() {
super.setUp()
// We don\'t need to inject the dependencies inside the `init`
viewModel = ShareViewModel(post: post)
}
// All the following test cases are unaffected
func testLoadFriendsSuccess() async {
// GIVEN
let friends = [
User(id: 1),
User(id: 2),
User(id: 3)
]
friendsRepository.getFriendsResult = .success(friends)
// WHEN
await viewModel.loadFriends()
// THEN
XCTAssertEqual(viewModel.friends, friends)
}
func testLoadFriendsFailure() async {
// GIVEN
friendsRepository.getFriendsResult = .failure(TestError.testCase)
// WHEN
await viewModel.loadFriends()
// THEN
XCTAssertEqual(viewModel.friends, [])
}
func testShareSuccess() async {
// GIVEN
shareService.shareError = nil // Not needed, added for clarity
// WHEN
await viewModel.share(with: User(id: 1))
// THEN
XCTAssertEqual(notificationService.showSuccessCallCount, 1)
}
func testShareFailure() async {
// GIVEN
shareService.shareError = TestError.testCase
// WHEN
await viewModel.share(with: User(id: 1))
// THEN
XCTAssertEqual(notificationService.showErrorCallCount, 1)
}
}
When to use each type of Dependency Injection
In my experience, Init based DI is the simplest between both approaches. However, we lose the ability to configure a global container for DI. So, here is the trick. We can divide dependencies in local and global.
- Local dependencies are the dependencies that only make sense within a module or a small set of types. View models, for instance, are local dependencies. If you use navigation coordinators, interactors, presenters, etc. they are likely to be local dependencies as well.
- Global dependencies are the dependencies that are used in many different places across the application. Repositories, network clients, services are examples of global dependencies.
If everything was a local dependency, instantiating dependencies across the app could lead to a lot of boilerplate and repeated code.
If everything was a global dependency, the container could be full of dependencies that are only needed on a specific part of the application, polluting the container initialization code and breaking encapsulation.
So my rule of thumb here is:
- Use Init-based DI for all local dependencies, keep them contained within the scope where they should be used.
- Use Container-based DI for global dependencies, define a container configuration to be used in your main app target, and another configuration to be used on tests. Keep the code on them as simple and straightforward as possible.
Conclusion
DI is a very broad term, so it can be implemented in many different ways. In this article, we explored two different ways of implementing it, with code examples and advice on when and how to implement each of them. We also explain how they can be used for unit testing.
There are many other interesting uses of dependency injection, like communicating different modules when developing apps at scale. However, that is out of scope for this article.
I hope this has been useful for you, and that what you learned here will help you design better, more testable code.