If we’re not careful, we pass around deeply nested dependencies through various types that don’t need them, muddying up the code. Not only is this “inelegant”, it also makes it harder to deliver your types as standalone components.
We are dealing with transitive dependencies. Also known as the dependencies of dependencies.
In this article, we’ll learn how to handle them elegantly, without resorting to fancy solutions.
Transitive dependencies are not to be confused with transient dependencies, which means fleeting dependencies. Which, as I understand, is not a thing.
It just sounds similar. People, including me, mix them up sometimes.
To handle deeply nested dependencies, many mobile devs eagerly resort to fancy solutions, such as trending third-party frameworks, singletons, or service locators.
Especially static or singleton solutions make it easier to “just grab” the dependencies without passing them all around.
Singletons or global state come with their own trade-offs and problems. Including making it harder to make modules out of your features and components.
To learn why, check out Chapter 6. Dependency Injection foundations in the Mobile System Design book.
Others may want to roll out their own solutions; Such as using containers that store a bunch of dependencies. Then they pass the container around. Granted, it makes it easier to add dependencies this way. Unfortunately, with this approach it’s not obvious there are 20 dependencies hiding as stowaways.
Yet, others might introduce various interfaces, which do not expose all properties to intermediate types.
These solutions allow developers to “hide” any dependencies. However, even though it’s not explicit, the dependencies are still being passed around types that don’t need them! It’s just less obvious. They are, essentially, sweeping the problem under the rug.
That’s why I want to share some lessons and a surprisingly simple solution, so that you too can go “ohhhhh, I get it now. We don’t need that Third-Party Service Locator Singleton Injector interface, after all!”
Even better, the solution in this article works for most programming languages, unbound by any framework or platform.
Sometimes, platforms offer elegant solutions to pass data or dependencies to deeply nested views, such as SwiftUI's Environment
. But that has its own trade-offs. Not to mention, this is limited to SwiftUI, not a solution for all platforms. On top of that, this won't help you during coding interviews using a web browser, or when working outside of the UI layer.
Let’s look at solving the tiniest transitive dependency problem, two levels deep, after which we’ll increase the difficulty.
Imagine we have a ProductRepository
which depends on DataStore
to supply some raw data, which depends on NetworkClient
to fetch and synchronize some data with the backend.
ProductRepository
does not use NetworkClient
directly.
Note that ProductRepository
does not care how DataStore
supplies its data. It doesn’t care whether it loads it from insecure local text files, an encrypted database, gets it from the backend, or that it receives data from mail-carrier pigeons.
ProductRepository
just wants to say “DataStore, give me some data. Just get it done.” This means that ProductRepository
doesn’t need to depend on NetworkClient
directly, since ProductRepository
doesn’t use it.
But, the problem is, if we’re not careful, we will pass NetworkClient
to ProductRepository
. This might happen if ProductRepository
initializes DataStore
.
To avoid playing favorites, let's use a Swift-Kotlin Frankenstein hybrid language, because the programming language doesn't matter too much to the concepts in this article.
class ProductRepository {
val dataStore: DataStore
init(networkClient: NetworkClient) {
self.dataStore = DataStore(networkClient: networkClient)
}
// ... snip
}
ProductRepository
accepts the transitive NetworkClient
to initialize a DataStore
. Even though ProductRepository
doesn’t directly use NetworkClient
, it’s now aware of it.
The reason ProductRepository
does not initialize NetworkClient
, is that it can be swapped out and is passed from its parent. NetworkClient
could be a mock client, or one that connects to staging servers instead of production servers. Which is why something outside of ProductRepository
will pass the instance.
As a result, ProductRepository
now becomes tightly coupled to NetworkClient
, despite not using it.
This example may appear contrived, but this happens often in the wild.
It’s not the end of the world if this happens. In fact, it’s normal that this happens here and there in a codebase, but we can consider this a code smell if it happens everywhere by default.
If we exaggerate this, and keep taking this approach all the time. Then, over time, we’re passing all dependencies around to where they’re needed. That would cause us to weave all types across the entire codebase.
To better depict the problem, we’ll expand DataStore
so that it requires three dependencies. On top of that, let’s introduce yet another direct dependency, Analytics
which depends on a User
and AnalyticsStore
.
However, with our current approach, we don’t have a clean graph. Since ProductRepository
is aware of its transitive dependencies, it looks more like this, instead.
We'll mark transitive dependencies with a dashed line.
Already, it’s becoming quite convoluted with only a few types, two layers deep.
Looking at the code, we can see the result. ProductRepository
is growing together with the transitive dependencies. It now requires all transitive dependencies to initialize its direct dependencies.
class ProductRepository {
val dataStore: DataStore
val analytics: Analytics
// ProductRepository receives transitive dependencies
init(networkClient: NetworkClient,
userSettings: UserSettings
secureStorage: SecureStorage,
user: User,
analyticsStore: AnalyticsStore) {
// We use transitive dependencies to initialize direct dependencies
self.dataStore = DataStore(networkClient: networkClient,
userSettings: userSettings,
secureStorage: secureStorage)
self.analytics = Analytics(user: user
analyticsStore: analyticsStore)
}
// ... snip
}
I can get used to this Swift-Kotlin blasphemous hybrid. Let's invent yet another multi-platform solution! /s
If we’re not careful, ProductRepository
becomes a dependency-configuration hub.
This means that whenever we change any transitive dependency, ProductRepository
needs to be updated, too.
For example, if DataStore
doesn’t need UserSettings
anymore, we now need to break open and refactor ProductRepository
. The worst part, ProductRepository
doesn’t even directly need any of these. That’s quite a pain!
Imagine if we want to take ProductRepository
and move it into its own module. It now not only depends on DataStore
and Analytics
, but it now also depends on five transitive dependencies! This makes it harder to move this type around. We can’t easily “cut and paste” it.
Now imagine this happening all over the codebase. It becomes a tangled web of tightly coupled dependencies that are hard to maintain.
At this stage, it becomes tempting to just wrap these all inside one nice container, to make ProductRepository
's initializer more manageable. But, again, we'd be sweeping the problem under the rug.
The answer to this problem is to ensure that types are only aware of their direct dependencies. Let’s continue to see how.
You are free to pick your favorite solution. But in this article, I want to show you that you need nothing special to prevent this problem. Against popular belief, we don’t need interfaces either.
A simpler solution is to flip the order of initialization around.
First, we’ll grab the graph from before, where ProductRepository
doesn’t know about its transitive dependencies anymore. This would be the “ideal” graph. For example, ProductRepository
doesn’t know about UserSettings
or SecureStorage
.
Next, we’ll take the dependencies, and flip them upside-down. We do this, because this helps us translate them into code, as you’ll see in a moment.
The dependencies are still the same, mind you. They are just represented differently.
So far, so good. Next, let’s use this graph to guide our implementation.
We flipped the graph upside down, because this better represents the order in which we initialize all types.
At the top row of the graph, we have the leaf dependencies. They don’t depend on anything in particular. At least, we don’t need to pass any dependency to them .
We start by creating the top row first, and initialize User
, AnalyticsStore
, NetworkClient
, UserSettings
, and SecureStorage
.
We can start with these leaf dependencies, because these types don’t have dependencies themselves.
We’ll do this in a class that sets up the dependencies, let’s call it Configuration
. But it can be anywhere you want, really, Main
, AppDelegate
, you name it.
class Configuration {
fun makeProductRepo() -> ProductRepository {
// Initialize the first row.
val user = User()
val analyticsStore = AnalyticsStore()
val networkClient = NetworkClient()
val userSettings = UserSettings()
val secureStorage = SecureStorage()
// ... snip. we aren't done yet
}
}
Now we take the next row and initialize that using things from the first row.
Using these instances, we can now go one layer below, and initialize Analytics
and DataStore
.
class Configuration {
fun makeProductRepo() -> ProductRepository {
// Initialize the first row.
val user = User()
val analyticsStore = AnalyticsStore()
val networkClient = NetworkClient()
val userSettings = UserSettings()
val secureStorage = SecureStorage()
// New: We initialize the second layer.
val analytics = Analytics(user: user, store: analyticsStore)
val dataStore = DataStore(networkClient: networkClient,
userSettings: userSettings,
secureStorage: secureStorage)
// ... almost done
}
}
Now that we have instances of Analytics
and DataStore
, we can go one layer deeper again, and finally initialize ProductRepository
.
class Configuration {
fun makeProductRepo() -> ProductRepository {
// Initialize the first row.
val user = User()
val analyticsStore = AnalyticsStore()
val networkClient = NetworkClient()
val userSettings = UserSettings()
val secureStorage = SecureStorage()
// Second row
val analytics = Analytics(user: user, store: analyticsStore)
val dataStore = DataStore(networkClient: networkClient,
userSettings: userSettings,
secureStorage: secureStorage)
// New: Last row
val productService = ProductRepository(analytics: analytics,
dataStore: dataStore)
// We initialized ProductRepository, now we can return it.
return productService
}
}
Instead of "passing values" we could also say we are "injecting" them or we could say we are applying "Dependency Injection with Inversion of Control". But, I think that just sounds pretentious. Dependency injection really is an expensive term to say "passing values around".
This may seem boilerplatey. But, it’s the same boilerplate from before, just in a different location.
The best part: Not a single type is aware of their transitive dependencies!
Looking at ProductRepository
, all it has now is two direct dependencies, thus simplifying this class, the same goes for all other types.
class ProductRepository {
val dataStore: DataStore
val analytics: Analytics
// ProductRepository now only has direct dependencies
init(dataStore: DataStore, analytics: Analytics) {
self.dataStore = dataStore
self.analytics = analytics
}
// ... snip
}
This solution works great for our problem, which was only two layers deep. On a local level, this happens often enough, and we can apply this upside-down trick to solve this problem regularly.
You might think that this solution will not work on a “normal” scale, but surprisingly, it scales up quite a bit.
Let’s grow the problem and solve it again.
Imagine that we have a TabBarController
which has a dependency on NavigationController
, which requires a ProductRepository
to set up the entire navigation.
We basically extended the dependency-tree from three to five levels.
Updating the code isn’t a big deal at all. Looking at the Configuration
class again, we can see we merely add the rows again, just like before.
class Configuration {
// We now return a TabBarController
fun makeTabBarController() -> TabBarController {
// This is all the same:
// Initialize the first row.
val user = User()
val analyticsStore = AnalyticsStore()
val networkClient = NetworkClient()
val userSettings = UserSettings()
val secureStorage = SecureStorage()
// Second row
val analytics = Analytics(user: user, store: analyticsStore)
val dataStore = DataStore(networkClient: networkClient,
userSettings: userSettings,
secureStorage: secureStorage)
// Third row
val productService = ProductRepository(analytics: analytics,
dataStore: dataStore)
// This is the only new code:
// NEW: Two new rows
val navigationController = NavigationController(productService: productService)
val tabbarController = TabBarController(navigationController: navigationController)
// We initialized TabBarController, now we can return it.
return tabbarController
}
}
With little effort, we can add more layers to the code. It’s effortless because we made sure we aren’t weaving transitive dependencies through all types.
If you think this method is becoming too large, we can solve this by cutting it up. Such as offering a makeProductRepository()
next to makeTabBarController()
method, and so on.
This solution works well on a small to larger scale and avoids a very common problem.
Now you might wonder: “Does everything need to be injected at one configuration point?”
No, you can pick configuration points in the app. Notice how Configuration
is aware of all dependencies. That is the one location in our example where a class knows about its transitive dependencies.
In a real-world application, you may have one at the main entry point, and one after logging in. Because usually, after logging in, you get a User
instance which you can use to initialize a ton of new dependencies.
Another location might be a settings screen with dozens of dependencies to configure each feature in the app. It might be wise to put a configuration point there.
Yet another location might be entry-points of feature-modules. A feature-module might know how to configure itself, after which the feature has no tight-coupling between transitive dependencies.
One underdeveloped side-effect of our approach is that we initialize everything upfront, or eagerly.
For example, we might not even need NavigationController
before logging in, yet we are already initializing the entire dependency-tree for it.
This article is already becoming quite long, but one solution here is to use factory methods. This allows you to initialize a dependency solely when needed.
Another topic that doesn’t fit this article is handling dependencies across modules. Because differently rules apply if we have feature modules that set up their own configuration points.
Both these topics are covered extensively in the Mobile System Design book.
With a simple approach, we tamed a five-level dependency hierarchy.
Note that we didn’t rely on anything fancy; We didn’t need singletons, interfaces, service locators, and so forth. We are merely passing values around, just in a more self-aware order. This is a big win!
Not everything fits in one article, since dependencies are a larger and contentious topic. Entire books have been written about it. But, I always recommend to first try to keep it simple before resorting to fancy solutions.
That’s why the Mobile System Design book contains three chapters dedicated to dependencies. The emphasis is to keep it simple, staying away from fancy frameworks.
Check out chapters:
I promise you that this is all you need to create large, scalable, mobile apps, without relying on third-party Dependency Injection frameworks.
The Mobile System Design book
If you want to boost your career and become an even better mobile engineer, then check out the Mobile System Design book.
I am the author of this book, but you may also know me from the highly-rated Swift in Depth book.
Even if you're a senior or staff-level engineer, I am confident you'll get a lot out of it.
It covers topics in depth such as:
... and much more!
Get the Mobile System Design bookWritten by
Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter 1.0 and iOS Tech Lead at ING Bank.
He is the author of the Mobile System Design and Swift in Depth books.