Create a type-safe Dependency Injection Container for an iOS application. Part 1

Aleksandr Sychev
Lonto
Published in
9 min readJun 29, 2023

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 the View so that someone from the outside could display it. In between, it collects all dependencies for the View.
  • ViewModel stores the state, accepts events from View, 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 the View 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! 🤘📲

--

--