Mobile System Design

By the author of the Mobile System Design and Swift in Depth books
Tjeerd in 't Veen

Written by

Tjeerd in 't Veen

X

Mastodon

LinkedIn

Youtube

Instagram

Newsletter

Reader

Elegantly Handling Transitive Dependencies

Jun 17, 2024, reading time: 14 minutes

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.

Overcomplicated solutions

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.

The problem

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.

The problem grows rapidly

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.

A solution

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.

Implementing the dependencies

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.

Multiple levels deep

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.

Evaluating this approach

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.

Maturing this approach

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.

Conclusion

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:

  • 6. Dependency Injection foundations
  • 7. Sane Dependency Injection without fancy frameworks
  • 8. Dependency Injection on a larger scale

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:

  • Passing system design interviews
  • Large app architectures
  • Delivering reusable components
  • How to avoid over-engineering
  • Dependency injection without fancy frameworks
  • Saving time by delivering features faster

... and much more!

Get the Mobile System Design book

Written 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.

Stay up to date and subscribe to the newsletter to get the latest updates in your mailbox.