Create a type-safe Dependency Injection Container for an iOS application. Part 1
Hello, reader! My name is Aleksandr, and I am an iOS tech lead at Lonto.
In the series of articles, I will share my ideas of Dependency Injection and try to solve the main issue of the library solutions for DI: we need to know exactly that the screen will be assembled, dependencies will catch up, and all errors will be identified at the compilation stage.
To dig into and cover most of the possible pitfalls, we need a large project with many screens, ideally, broken down into modules. What I offer is to go this way together and write the project together with me.
I plan a series of articles, where we will build up the code base step-by-step and cover such issues as circular dependencies, lifecycles, lazy loading and container decomposition.
Are you ready? Let’s move on! 🏎
For a start, let’s make up an approximate plan of what we get throughout the series. So far, I have a clear understanding of what we have in the beginning and some vague ideas of what we’ll get in the end. Just keep it in mind.
Contents of Article №1
- Dependency Injection
- Types of Dependencies
- The Project: Writing a Container and Making Sure it Copes Well
- Results of Part 1
What we’ll cover in other articles:
- Dependency Lifecycle and How to Manage it
- Container decomposition, circular dependencies
- Lazy initialization
- Let’s try to get rid of the boilerplate code when creating objects
- Project Modularization
If any ideas occur to me, or the community offers other requests when writing the articles, I can turn on a dime. I really enjoy this kind of flexibility 👌
For start, let’s clarify how we understand the Dependency Injection principle. Let’s begin with terminology.
Dependency Injection
💡In my understanding, dependency for a certain object is any external entity that helps this object do what it should.
For example, you have two screens: a list and details view: ListViewController
and DetailsViewController
. Let’s say ListViewController
ходит retrieves data from the backend, and ItemService
helps in doing it. This is a dependency.
Besides, not to get trapped by the Massive View Controller, we moved the business logic away to ListPresenter
. This is also a dependency. If we open DetailsViewController
transferring the list element id
to it, id
is also a dependency. So…
💡Dependency Injection is a pattern that offers to implement all dependencies from the outside, and not to initialize them inside the object itself.
We can use a variety of techniques to do that, but today we’ll focus on one of them: implementing through the constructor in the init()
method. What do we need it for? To ensure independence. Funny word play, eh? Independence of the object upon its dependencies: if they come from the outside, we can replace them with anything else any time. For example, with test mocks. Or with a red-button implementation instead of a blue button in A/B test.
Types of Dependencies
1️⃣
The data available only in the runtime.
Example: id
of the element from the list. No matter what method of dependency delivery we choose — with or without DI — we deliver them from the outside, as there are no dependencies and they can’t appear inside.
2️⃣
What we can create when initializing the object.
Example: ListPresenter
. Let’s say, we have the VIPER architecture and ListAssembly
that assembles the entire VIPER module. It’s possible to create entities of this module simultaneously and to set connections between them, as all of them are destined to live simultaneously and no other way.
3️⃣
Dependencies the object shouldn’t know much about. It’s for their sake that the Dependency container pattern appeared.
Example: ItemService
. ListAssembly
can claim that it needs ItemService
, but it probably doesn’t know, how to create it itself. It’s because ItemService
dependencies — for example, HttpClient
or Repository
to access the database — are beyond the area of responsibility of a certain screen.
Agree? Then let’s move on and get acquainted with the project.
The Project
So far I have only two screens. I am sure you know what they are! List
and Details
.
So far, it doesn’t really matter what entities are included in this list, and what data the Details screen shows. The important point is that we really get the data with ItemService that retrieves it from the database. I haven’t yet connected the backend. And, of course, I jump into the hype train and write all of this on SwiftUI. It may seem to you that I am not really good at SwiftUI, but I think I’ll become a guru by the end of the series of these articles. Let’s see ☝️👀️
The architecture of my project is MVVM. With routers. Don’t ask. I just wanted to show in living color that sometimes dependencies must be created together with the object, and it’s the best thing I could think of. I believe I still have a chance to invent a better example.
Thus, this is how it looks like:
-- MyApp.swift
-- Model
|-- Item.swift
-- Persistence
|-- PersistenceController.swift
-- Service
|-- ItemService.swift
-- Views
|-- List
|-- ListAssembly.swift
|-- ListViewModel.swift
|-- ListView.swift
|-- ListRouter.swift
|-- Details
|-- DetailsAssembly.swift
|-- DetailsViewModel.swift
|-- DetailsView.swift
Now, a couple of words about responsibility of the entities:
Assembly
is a screen builder. Its task is to return theView
so that someone from the outside could display it. In between, it collects all dependencies for theView
.ViewModel
stores the state, accepts events fromView
, and updates the state.View
— what can you say? It’s self-explanatory.Router
is a somewhat dwindled entity in the SwiftUI context, but I still don’t quite understand how to make navigation beautifully. In this project, we need it to illustrate work with the container. It’s in charge of getting theView
to display the following screen.
And some code:
@MainActor
final class ListAssembly {
func view() -> ListView {
let service = ItemService(persistence: PersistenceController.shared)
let router = ListRouterImpl()
let viewModel = ListViewModel(service: service, router: router)
return ListView(viewModel: viewModel)
}
}
As you see, I already have DI: Both ListView
and ListViewModel
get their own dependencies in the constructor. For the context — ListView
, ListViewModel
and ListRouter
code:
struct ListView: View {
@ObservedObject private var viewModel: ListViewModel
init(viewModel: ListViewModel) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
List {
ForEach(viewModel.items) { item in
NavigationLink {
viewModel.detailView(by: item.id)
} label: {
Text(item.text)
}
}
}
}
}
A simple screen with a table. Please note how View
and ViewModel
interact:
@MainActor
final class ListViewModel: ObservableObject {
struct Item: Identifiable {
let id: Int
let text: String
}
@Published var items: [Item] = []
private let service: ListService
private let router: ListRouter
init(service: ListService, router: ListRouter) {
self.service = service
self.router = router
Task {
do {
items = try await service.items().enumerated().map { index, item in
Item(id: index, text: itemFormatter.string(from: item.timestamp))
}
} catch {
print("No items")
}
}
}
func detailsView(by id: Int) -> DetailsView? {
guard let item = items.first(where: { $0.id == id }) else { return nil }
return router.detailsView(with: item.text)
}
}
ViewModel
can do three things: get data from ItemService
, save them in the items
array and return the DetailsView
instance through accessing ListRouter
:
@MainActor
protocol ListRouter {
func detailsView(with text: String) -> DetailsView
}
@MainActor
final class ListRouterImpl: ObservableObject {}
extension ListRouterImpl: ListRouter {
func detailsView(with text: String) -> DetailsView {
return DetailsAssembly(text: text).view()
}
}
So far, ListRouter
creates the DetailsAssembly
instance by itself.
And this is how the first screen is opened:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ListAssembly().view()
}
}
}
The main issue of this code is that the Assembly of a certain screen creates a service instance.
If, right now, DetailsViewModel
needs to download the data with ItemService
for detailed display (which is quite probable), DetailsAssembly
will have the same string:
let service = ItemService(persistence: PersistenceController.shared)
Not to mention that we have to use PersistenceController
singleton, so that both ItemService
instances would have one and the same PersistenceController
instance. And if we imagine that we add a new abstraction layer between the service and the base — ItemRepository
? Now we’ll have to write the following in two places:
let repository = ItemRepository(persistence: PersistenceController.shared)
let service = ItemService(repository: repository)
And now let’s imagine that we have ten screens instead of two. And what if we have more abstraction layers?
I suggest resolving this issue with the Dependency container.
Dependency container is the entity that delivers external dependencies. The container decides, if (and when) it is necessary to create a new instance, to store intense or subtle references. The entire dependency logic is encapsulated in it.
Now Assembly will get the input data that appear only in the runtime (dependencies of the first type in the beginning of the article), create instances of View, ViewModel and Router (dependencies of the second type) and request from the container the entities, whose creation the screen is not supposed to be aware of, for example, services (dependencies of the third type).
Since so far we have only one container — AppContainer
— it will have no dependencies of the third type. It will know about all entities. Respectively, the container will get the input dependencies of the first type, and create dependencies of other types. Its first task will be to create ListAssembly
to exclude its creation from MyApp.swift
:
@MainActor
final class AppContainer: ObservableObject {
func makeListAssembly() -> ListAssembly {
ListAssembly()
}
}
Quite a simple code. Now we need to teach ListAssembly
to request ItemService
from AppContainer
.
Let’s work in reverse order: first request dependencies, and then implement their delivery to make sure that the compiler helps us at every stage.
And here’s another important point: I don’t want ListAssembly
to be able to request something extra from AppContainer
, that’s why we will hide it under the protocol.
protocol ListContainer {
func makeItemService() -> ItemService
}
@MainActor
final class ListAssembly {
private let container: ListContainer
init(container: ListContainer) {
self.container = container
}
func view() -> ListView {
let service = container.makeListService()
let router = ListRouterImpl()
let viewModel = ListViewModel(service: service, router: router)
return ListView(viewModel: viewModel)
}
}
Great! Nothing is built. The compiler says that we haven’t transferred a new dependency — ListContainer
— to the ListAssembly
constructor. Let’s correct it:
func makeListAssembly() -> ListAssembly {
ListAssembly(container: self)
}
Now the compiler reprimands us that AppContainer
does not correspond to the ListContainer
protocol. Let’s correct it:
extension AppContainer: ListContainer {
func makeItemService() -> ItemService {
ItemServiceImpl(persistence: PersistenceController.shared)
}
}
But wait a second! Now we have a container, and we don’t have to use PersistenceController
singleton. Let the PersistenceController
instance be stored in AppContainer
as well:
@MainActor
final class AppContainer: ObservableObject {
private let persistenceController = PersistenceController()
func makeListAssembly() -> ListAssembly {
ListAssembly()
}
}
extension AppContainer: ListContainer {
func makeItemService() -> ItemService {
ItemServiceImpl(persistence: persistenceController)
}
}
Great! Everything works fine. Now we need to get rid of explicitly creating DetailsAssembly
in the list router:
@MainActor
final class ListRouterImpl: ObservableObject {
private let container: ListContainer
init(container: ListContainer) {
self.container = container
}
}
extension ListRouterImpl: ListRouter {
func detailsView(with text: String) -> DetailsView {
return container.makeDetailsAssembly(text: text).view()
}
}
The compiler is angry again. Let’s correct:
protocol ListContainer {
func makeItemService() -> ItemService
func makeDetailsAssembly(text: String) -> DetailsAssembly
}
@MainActor
final class ListAssembly {
private let container: ListContainer
init(container: ListContainer) {
self.container = container
}
func view() -> ListView {
let service = container.makeListService()
let router = ListRouterImpl(container: container)
let viewModel = ListViewModel(service: service, router: router)
return ListView(viewModel: viewModel)
}
}
extension AppContainer: ListContainer {
func makeItemService() -> ItemService {
ItemServiceImpl(persistence: persistenceController)
}
func makeDetailsAssembly(text: String) -> DetailsAssembly {
DetailsAssembly(container: self, text: text)
}
}
So far, DetailsAssembly
depends on ListContainer
, but I will correct it in future articles of this series, when we consider container decomposition. The most important thing we’ve got is the opportunity to reduce creation of ItemService
instance to one place, while it’s used in many places.
Results of Part 1
- We have set the general context for the articles: what dependencies are, what types of dependencies exist and what the Dependency Injection is. These definitions are my interpretation for general understanding of future content
- Based on the example, we have discovered the danger of creating dependencies of the third type right before creating the object that depends on them
- We used the Dependency container pattern we hid under the protocol and learned to request dependencies of the third type
In the next article, I’ll continue to develop the container. We’ll see what we can teach it in the larger project.
Stay tuned! 🤘📲