Ultimate Guide to Dependency Injection for Modular iOS app

Over the years, we’ve been told to use dependency injection to help our apps scale better. On the other hand, design patterns like MV suggest simplifying app design to the minimum, often overlooking proper dependency management. While most of us instinctively understand that Dependency Injection is crucial for maintainability, testability, and flexibility, important questions remain: How much flexibility and scalability does it really offer? Is it worth all the fuss when I can simply use SwiftUI’s Environment? Can the DI perform in really complex, modular apps? These are excellent questions to explore. 

You might be asking yourself: Is this blog post for me? My app is simple and I have tight deadlines… Even if you’re not building complex apps right now, understanding the pros and cons of different DI patterns will help you choose the right solution when you need it. We’ll cover practical examples, best practices, and common pitfalls to avoid, putting the tested solution up against the ultimate challenge – modular iOS applications. Strap in and let’s go! 

Why should we even consider using Dependency Injection in our apps?

Let’s start with the basics – what is Dependency Injection? If you look up a formal definition, it’s a design pattern that promotes decoupling components in your code. Instead of having objects create their own dependencies directly, these dependencies are injected from the outside.

For a simpler definition, it’s just providing an object with everything it needs to do its job. Think of it like packing for a week-long outdoor trip. Everything you take with you must last the entire trip since there won’t be any grocery stores around. This analogy works on another level too: you need to be selective about what you pack. Take too much, and you’ll quickly get worn out.

Only the essentials - Modular Dependency Injection: Implementing scalable iOS apps

At first, it looks like a lot of work (and rightfully so). What are the benefits of Dependency Injection? Why should we use it instead of letting our objects create what they need?

Let’s consider a practical example: we need to create an object that provides basic data of a signed-in user – name and email. Let’s call it UserDataManager. This manager needs to persist data between app launches, so it requires access to local storage. We’ll use UserDefaults to keep things simple. There are two ways to handle this: either allow UserDataManager to create its own UserDefaults instance, or inject it.

Let’s explore how injection can help us write better code:

  • Making our code more flexible and easier to modify:
    Let’s assume our app goes through a security audit, and we are advised to change the storage implementation to a more secure one: Keychain, encrypted file storage, etc. If we inject the storage into the UserDataManager, we could easily replace the UserDefaults storage with the one recommended by the pentesters. And if we invested some time to abstract the storage, the change should be straightforward.
  • Improving testability:
    This goes without saying: it’s extremely difficult to achieve reliable, exhaustive unit test coverage without proper dependency injection in place. Some argue that we can write reliable tests for tightly coupled components (e.g. storage with locally created UserDefaults) by verifying what was written, read, and removed from the actual storage. They might point out that such tests better verify real-life scenarios – and I agree. However, we need to remember to set up the storage before tests and clean it up afterward, backing up any production data that might reside there. This approach isn’t perfect, and sometimes it’s even impossible to maintain.
  • Enabling better separation of concerns:
    As a start, our object has one less responsibility – it no longer has to create the tools it needs to do the job. You might think it’s not much, but in complex applications, even something simple like initializing a network session can require significant setup work (providing configuration, setting up error handling, etc.). Next, using Dependency Injection makes implementing do-it-all components uncomfortable, as we would have to supply them with many dependencies. It’s easier to split such components into smaller, more specialized ones and keep reusing them as dependencies for other components. Makes sense, right?

As you can see, Dependency Injection brings more value as our apps grow in complexity. You might get by without it when building a quick proof of concept… for a time. In my experience, few POCs actually stay that way. Most are eventually pitched to potential customers as fully developed products, assigned a Project Manager, space in Jira and unrealistic deadlines…

What different types of Dependency Injection are there in Swift?

There are a couple of ways to inject the dependencies into our objects in iOS apps. Let’s take a look:

  • Constructor (Initializer) Injection:
    This is my preferred way to inject dependencies in Swift. Arguably, it’s also the safest, as when implemented correctly, it ensures all dependencies are available from the moment the object is created. How does it work? We simply pass all dependencies through the initializer of an object that needs them. The object then decides whether to hold references to them (true in the vast majority of cases) or not. These local references should be immutable and private to avoid exposing implementation details. It’s also worth defining default values for some of these parameters. E.g. an object providing a current date will likely be substituted with a non-default implementation only in tests.
class UserService {
    private let dataProvider: DataProvider
    init(dataProvider: DataProvider) {
        self.dataProvider = dataProvider
    }
}
  • Method Injection:
    Another effective way to pass dependencies is to do so through a dedicated method. While slightly less safe than constructor injection since dependencies aren’t available at object creation and references are mutable, this approach has its place. When dependencies are properly encapsulated, the risk of accidental modifications from outside the object remains minimal. This pattern becomes particularly useful when we can’t wait for all dependencies to initialize before creating an object:
class MyService {
    private var dataProvider: DataProvider?
    func register(dataProvider: DataProvider) {
        self?.dataProvider = dataPRovider
    }
}
  • Property Injection:
    Finally, we have property injection – where dependencies are set via publicly available variables. I must admit I’m not a big fan of this approach. This is mainly because such variables must remain mutable throughout the object’s lifecycle. While one might mitigate this requirement by using property wrappers that ignore subsequent attempts to set a dependency, is this really the best (and safest) way to do this? I’ll let you be the judge:
class OrderService {
    var paymentService: PaymentService?
}

What about the DI methods provided to us by SwiftUI through Environment and EnvironmentObject? Looking closer, we can see that it’s just a variant of Property Injection where the injection is physically handled by SwiftUI. Thanks to this automatic injection, we can rest assured that dependencies won’t be switched unless we explicitly do so by resetting them at the global Environment / EnvironmentObject injection level.

Finally, let’s briefly touch on Dependency Injection driven by metaprogramming, e.g. by using Swift Macros or Sourcery. While these tools seem like a perfect, boilerplate-free way to ensure dependencies are provided across the app, remember that under the hood, the code these macros generate still uses one of the three DI methods listed above.

How can we implement Dependency Injection in complex, modular iOS apps?

All of that is fine, but how does it apply to real-life, complex apps? Let’s crank it up a bit – what about modular apps? Can we reliably provide dependencies for a given module without exposing its implementation details? Can we avoid memory leaks, duplicates, crashes, and circular dependencies?

Well, the best way to find out is to run a small experiment. Let’s look at the following modular to-do list iOS app:

The app blueprint resembles the one I described in my article about building scalable, modular iOS apps.

At the foundation, we have Common modules. They contain all the shared data models, protocols describing critical app services (e.g. LocalStorage), reusable UI components, helpful extensions, etc.

Up a floor, we have utility modules, like Storage. These are more specialized than the Commons but don’t provide any business value by themselves. They typically don’t contain UI either. These modules focus on providing specialized, targeted functionality to the components higher up the food chain.

Next, we have feature modules, focused on delivering particular business value. In this example, we have: ManualDIToDoList, DependencyProviderToDoList and ThirdPartyDIToDoList. Each of these modules produces a simple to-do list feature, leveraging different dependency injection techniques. As these features mainly use shared UI components, there isn’t much code inside. As it should be! We don’t want to duplicate our UI components across different feature modules, do we?

At the very top of our pyramid, we have a wrapper module: DIShowcasePackage. It is the only user-facing SPM Product exposed by the modules package. Its sole job is to aggregate all the modules that the user-facing app requires and package them under a single wrapper. This way, in the app, we can access all the required features by simply using import DIShowcasePackage.

And finally, we have our app. It imports DIShowcasePackage as a local dependency and uses it to generate its features.

Our goal is to build a performant, testable app using modularized features while exploring three different approaches to dependency injection:

A) Manual Dependency Injection
B) Using (manually implemented) Dependency Provider
C) Using third party Dependency Injection library (Swinject)

The challenge here is that:

  • Each of the features comes from a different module. We must ensure proper scope for the public-facing APIs while encapsulating implementation details and avoiding circular dependencies.
  • Each feature requires a different way of passing dependencies. The factory creating each feature needs appropriate dependencies and must know how to extract them from their container or provider.
  • Each feature should reuse the main app resources (e.g. storage) without conflicts. When adding a note in the manual DI to-do list tab, it should appear in the other implementations: the DependencyProvider and third-party DI library lists.
  • Ensure the testability of all the modules. While testing reusable components or Storage is straightforward, can we achieve reliable coverage for feature modules?

Can we realistically achieve all of that? Let’s start with the most straightforward DI method below.

Option A: Manual Dependency Injection

Let’s start with something really simple – passing the dependencies directly to the modules that need them, without any proxies. To keep things nice and tidy, let’s create a dedicated Feature Module encompassing this approach – ManualDIToDoList. In the module, let’s expose a convenient factory producing the to-do list view that we could conveniently put on display in the main app:

@MainActor
public enum ManualDIToDoListFactory {
    @ViewBuilder public static func make(
        storage: LocalStorage
    ) -> TodoListView {
        TodoListView(
            viewModel: LiveTodoListViewModel(
                storageService: storage,
                title: "Manual DI"
            )
        )
    }
}

Our to-do list has just one dependency: local storage. At first glance, we could simply pass a UserDefaults instance and be done with it. But what happens when we need to switch to something more secure, like Keychain? A better approach is to create an abstraction called LocalStorage. Now, where should this protocol live? Since we’ll initialize storage in the main app with other dependencies, and ManualDIToDoList doesn’t need to know storage implementation details, we have two options: the Common or Storage modules. I follow a simple rule: How specialized is this service? For general, universal services, I place the protocol in the Common module. The LocalStorage implementation however will sit in the Storage module:

public protocol LocalStorage: Sendable {
    func save<T: Encodable>(_ items: T, forKey key: String) throws
    func load<T: Decodable>(forKey key: String) throws -> T?
    func remove(forKey key: String)
}

TL;DR: Our to-do list factory receives everything it needs to construct the view and its view model. Since LocalStorage is just a protocol, we keep implementation details hidden. As long as we maintain the same API, we can swap different storage types without affecting the rest of the app – and this flexibility extends to our tests too. We can easily create a LocalStorage mock to verify how the to-do list business logic interacts with storage:

@MainActor
struct ManualDIToDoListFactoryTests {
    ...

    @Test
    func whenViewIsCreated_shoulProvideItWithDependencies() async {
        //  given:
        let fakeStorage = FakeLocalStorage()
        fakeStorage.simulatedLoadedValue = [
            TodoItem(title: "First item"),
            TodoItem(title: "Second item"),
        ]

        //  when:
        let view = sut.make(storage: fakeStorage)
        view.viewModel.load()

        //  then:
        executeSnapshotTests(
            forView: view,
            named: "ManualDIToDoListFactory - Todo list view"
        )
    }
}

Finally, let’s set everything up and display it in the main app.

struct MainAppView: View {
    @State private var localStorage = makeStorage()
		
    var body: some View {
        TabView {
            manualDIToDoListView
                .tabItem { ... }
        }
    }
}

private extension MainAppView {
    @MainActor
    var manualDIToDoListView: some View {
        ManualDIToDoListFactory.make(storage: localStorage)
    }
}

Perfect! We’ve met all the conditions of our challenge: dependencies are provided across modules and the code is testable. If we want to change the LocalStorage implementation, we can simply create a new version using local files or Keychain. We could even create a dedicated module for this purpose – it’s a highly scalable solution!

So should we abandon other dependency injection tools and use manual injection in every app? Before we decide, let’s examine the pros and cons of this approach:

On the plus side, we don’t need any libraries or third-party tools to manage our dependencies. As a result, the code is clear, readable, and requires almost no learning curve. There’s also the benefit of compile-time safety, which prevents providing the wrong type or empty dependencies.

Unfortunately, manual dependency injection has a significant drawback: massive constructors. Consider a Flow Coordinator responsible for constructing and displaying several views in sequence (like a user authentication flow). Each screen would have a view model with business logic requiring dependencies. You can imagine how many dependencies would need to be passed to this Flow Coordinator to create all the views. While you could theoretically mitigate this by alternating between constructor and property injection, wouldn’t this undermine the main benefit of compile-time dependency checking?

Option B: Creating a Custom Dependency Provider

Ok, so we know that passing dependencies manually is taxing work. How can we make it easier?

What if, instead of passing each individual dependency, we packaged them in a container? What if that container came with a detailed set of instructions for retrieving each particular dependency? What if the container’s API could be defined in a way that allows every module in the app to access any registered dependency without being exposed to its implementation details? And that the solution would work equally well in production code and in tests? Say hello to the DependencyProvider.

So how does it work under the hood? A DependencyProvider is simply a container that offers an API for retrieving dependencies using unique keys. The most robust and convenient approach is to use the protocol type that each dependency implements as its key:

@MainActor public protocol DependencyProvider: AnyObject, Sendable {
    func resolve<T>() -> T
}

… and to retrieve a given dependency:

let storage: LocalStorage = dependencyProvider.resolve()

Let’s try out this approach by creating a new module called DependencyProviderToDoList. This module implements the same to-do list functionality but uses our custom dependency container instead of direct dependency passing. As before, we’ll create a factory that produces a view ready for display in the main app:

public enum DependencyProviderToDoListFactory {
    @ViewBuilder public static func make(
        dependencyProvider: DependencyProvider
    ) -> TodoListView {
        TodoListView(
            viewModel: LiveTodoListViewModel(
                storageService: dependencyProvider.resolve(),
                title: "Dependency Provider"
            )
        )
    }
} 

And let’s embed the view as a tab in the main app:

struct MainAppView: View {
    ...
    
    var body: some View {
        TabView {
            ...
            dependencyProviderToDoListView
                .tabItem { ... }
            ...
        }
    }
}

private extension MainAppView {
    @MainActor
    var dependencyProviderToDoListView: some View {
        DependencyProviderToDoListFactory.make(
            dependencyProvider: dependencyProvider
        )
    }
    
    ...
}

Great! The only thing left to do is to create the actual DependencyProvider and register dependencies we wish to pass to our app modules.

First, let’s make our DependencyProvider reference a @StateObject. This ensures it won’t be recreated each time MainAppView needs to redraw.

struct MainAppView: View {
		...
    @StateObject private var dependencyProvider = makeDependencyProvider()
    ...
}

Now, let’s create the provider and register the dependency we need:

private extension MainAppView {
		...
    @MainActor
    static func makeDependencyProvider() -> DependencyProvider {
        let dependencyManager = LiveDependencyManager()
        let storage = makeStorage()
        dependencyManager.register(storage, for: LocalStorage.self)
        return dependencyManager
    }
    ...
}

Finally, let’s ensure our solution is testable:

@MainActor
struct DependencyProviderToDoListTests {
    ...

    @Test
    func whenViewIsCreated_shoulProvideItWithDependencies() async {
        //  given:
        let fakeDependencyProvider = FakeDependencyProvider()
        fakeDependencyProvider.simulatedDependencies = [
            String(describing: LocalStorage.self): setupLocalStorage(),
        ]

        //  when:
        let view = sut.make(dependencyProvider: fakeDependencyProvider)
        view.viewModel.load()

        //  then:
        executeSnapshotTests(
            forView: view,
            named: "DependencyProviderToDoListFactory - Todo list view"
        )
    }
}

private extension DependencyProviderToDoListTests {
    func setupLocalStorage() -> LocalStorage {
        let fakeStorage = FakeLocalStorage()
        fakeStorage.simulatedLoadedValue = [
            TodoItem(title: "First item"),
            TodoItem(title: "Second item"),
        ]
        return fakeStorage
    }
}

Great! We’ve improved on the manual dependency injection approach by packaging dependencies into a single, convenient provider that can be passed throughout the app. As we witnessed, the provider enables type-safe dependency registration and retrieval, obfuscating implementation details. As a result, the solution shines in unit tests too, where we can create a fake provider to serve test dependencies. We can even apply the same strategy to keep Previews from interfering with production data. Nice

Unfortunately, there is no rose without thorns. In case of the DependencyProvider, it’s the compile-time safety we’ve given up for the sake of convenience. With manual DI, we were always guaranteed that the injected dependency existed before we passed it to the given component. Now we don’t have such luxury, which can lead to crashes when we attempt to retrieve a dependency that hasn’t been registered yet.

We could modify the DependencyProvider API to handle optional returns, but that raises even more concerns. Should we really force app components to handle missing dependencies defensively? I believe, in this particular case, it’s better to throw a fatalError when an unregistered dependency is requested and deal with it immediately. This issue becomes even more problematic when managing multiple applications from a shared codebase. Each such app would most likely maintain its own DependencyProvider that must stay synchronized with newly added dependencies.

From my experience, the best way to address this is through comprehensive unit testing across all applications built from the shared codebase. Additionally, I strongly recommend building a release version of each app as a final verification step of the CI/CD pipeline before allowing PRs to be merged. Since these builds can be parallelized, they shouldn’t significantly extend the build time.

Option C: Using Third-Party Dependency Injection

But what if you don’t want to write your own dependency provider? Even though the one we’ve just implemented is really simple, some people might be reasonably reluctant to reinvent the wheel. That’s fine, as there are several out-of-the-box, battle-tested libraries you can use: Needle, Resolver, Swift Dependencies, Swinject, etc., to provide dependencies across your app.

The challenge here is not finding a reliable library, but making sure it works within the modular app. Imagine the following scenario: you’re building a utility module like Storage. As expected, it needs a real storage mechanism to do its job: UserDefaults, Keychain, etc. To make things more challenging, let’s provide this storage using our chosen DI solution. In theory, the approach seems simple: wrap the storage in a protocol, register the implementation in the main app, and retrieve it in the Storage module. However, we need to consider the compilation order of app components. Low-level modules must be compiled first, followed by utility modules, then feature modules, and finally the app itself. So if our DI library offers compile-time dependency registration, it might be impossible to build even the low-level modules without providing implementations for the required dependencies. In fact, our DependencyProvider approach only worked because it offered runtime dependency registration…

Are we limited to DI solutions that exclusively use runtime dependency registration? Not necessarily. We can learn from SwiftUI Environment and how it handles this very challenge. Dependencies provided through Environment cannot be nil – you must register a default value at compile time when defining an EnvironmentKey :

public struct AppStyleProviderKey: EnvironmentKey {
    public static let defaultValue: AppStyleProvider = PlaceholderAppStyleProvider()
}

Later, when instantiating a View, you can override these default values:

let view = MyView(...)
    .environment(\.appStyleProvider, appStyleProvider)

But doesn’t Environment work only with SwiftUI View? How can we leverage this mechanism when injecting dependencies into other app components? Fortunately, there are tools that mimic the Environment approach to registering dependencies. The best example is probably the excellent Swift Dependencies. It offers a convenient way to register dependencies for production code, tests, and even SwiftUI Previews. You don’t even need to abstract every dependency with a protocol – you can register individual methods or properties provided by the dependency and use them across the app.

But there is a dark side to that approach as well. As convenient and safe as this approach might be, it requires a lot of boilerplate generation. Essentially, you’d have to register a placeholder dependency value in the module where that dependency lives:

extension APIClient: DependencyKey {
  static let liveValue = PlaceholderAPIClient(...)
}

… and override it in the main app whenever that dependency is about to be injected:

let model = withDependencies {
    $0[APIClient.self] = { ... }
  } operation: {
    TodosModel()
  }

… to be able to access it inside a given component:

@Observable
final class TodosModel {
  @ObservationIgnored
  @Dependency(APIClient.self) var apiClient
}

When you think about it, even with metaprogramming tools like Sourcery or Swift Macros to generate the boilerplate code for injection points, this approach demands considerable effort and creates potential points of failure in the app.

Fortunately, many third-party libraries provide runtime injection. One of the more popular ones is Swinject, which functions much like our homemade Dependency Provider, using a Container that stores references to registered dependencies:

@MainActor
static func makeSwinjectDependencyContainer() -> Container {
    let container = Container()
    let storage = makeStorage()
    container.register(LocalStorage.self) { _ in storage }
    return container
}

After registering dependencies, we can pass the Container to modules, allowing them to retrieve the tools they need:

@MainActor public enum ThirdPartyDIToDoListFactory {
    @ViewBuilder
    public static func make(
        dependencyContainer: Container
    ) -> TodoListView {
        TodoListView(
            viewModel: LiveTodoListViewModel(
                storageService: dependencyContainer.resolve(LocalStorage.self)!,
                title: "3rd party DI lib"
            )
        )
    }
}

The key distinction between Swinject’s Container and the DependencyProvider is how they handle missing dependencies. Swinject returns nil when requesting unregistered dependencies, giving you control over how to handle such cases.

In the end, your app’s architecture will likely guide the choice of dependency injection library. Simple, non-modular apps can benefit from compile-time DI tools. However, if you’re building a modular app or planning for long-term development, runtime-injection tools offer be a safer choice.

What Other Options Are Available?

Are there any other dependency injection techniques worth exploring? Sure, though I wouldn’t really recommend them (spoiler alert).

First, let’s consider using SwiftUI’s Environment or EnvironmentObject as injection points. On paper, this option looks fantastic. We have Apple’s own frameworks, designed to provide dependencies across SwiftUI ecosystem. These tools are simple to use, reliable, and guaranteed to have long-term support. They even cross module boundaries effortlessly! All you need to do to pass dependencies to a SignIn View is inject them at your SwiftUI app’s root view. Better yet, we can easily swap production dependencies with mocked versions for testing. What’s not to love?

If this solution was so great, this blog post would never exist. The biggest advantage of Environment / EnvironmentObject is arguably their biggest flaw when discussing dependency injection: tight coupling to SwiftUI. As S.O.L.I.D. developers, we want to apply separation of concerns and extract business logic into a ViewModel or similar dedicated object. That object requires certain dependencies to do its job. If we pass these dependencies through the Environment, we have to rely on the View to extract them and initialize the ViewModel. Beyond being awkward, this approach violates the Single Responsibility Principle. A view should present data to the user and await their feedback, not create business logic objects. Additionally, we burden the view with knowledge of the ViewModel’s implementation details, which conflicts with the Dependency Inversion Principle. A view should depend on ViewModel behavior (defined in a protocol), not its implementation details. Finally, what happens with this solution when parts of our app still rely on UIKit?

Altogether, although enticing at first glance, Environment / EnvironmentObject should be used with caution as an application-wide dependency injection tool.

Next, we can use good, old Singletons. One can argue that @Observable and Environment are nothing more than singletons, used to facilitate change observation across their respective domains. And I would agree. Some developers might point out that we should minimize the amount of code we write and leverage the combination of property injection and Property Wrappers to automate the injection process. And I would have to agree with that too. Finally, people might point out that we could mix the DependencyProvider technique with all of the above to create an easy-to-use, cross-modular DI solution with almost no entry cost. It might look similar to this:

@propertyWrapper
struct Inject<T> {
    let wrappedValue: T
    
    init() {
        self.wrappedValue = DependencyProvider.shared.resolve()
    }
}

and:

final class MyViewModel {
    @Inject private var networkService: NetworkService
}

Again, on paper it looks great. We no longer have to create massive constructors to inject our dependencies through. Our injection point is elegantly concise – just a simple @Inject before a variable declaration. Of course, it works cross-module as well. All we need to do is define all the abstractions describing the dependencies in a low-level module like Commons to make them accessible across the app. All that remains is creating live implementations of these dependencies in the main app and registering them with the DI Singleton of our choice – exactly as we showed when discussing the DependencyProvider. Sweet!

Unfortunately, this approach has a fatal flaw: testability. Singletons and unit tests don’t mix well. While there are workarounds – like creating a test-only AppDelegate to prevent production dependencies from initializing during tests, followed up by registering mocked dependencies with our singleton DependencyProvider– these solutions fall apart in practice. Modern testing frameworks like SwiftTesting rely on parallel execution and random test order, often running multiple test cases simultaneously.

Consider two test cases: one asserting proper behaviour of the Storage with no data persisted on the device, and another verifying retrieval of some persisted data. When these tests run in parallel while sharing a fake UserDefaults from a singleton, the results become unpredictable.

Yes, you could force the tests to run sequentially and have the singleton DependencyProvider clean up between tests, but the added complexity simply isn’t worth it.

To sum up: although there are many alternatives to the recommended dependency injection techniques, they simply have too many flaws to be considered for production apps at scale.

Summary

So, we’ve made it to the end! Thanks so much for sticking with me!

We started by discussing why non-trivial iOS apps need dependency injection in the first place. The sooner a sustainable DI solution is implemented, the more money it saves for the organization in time and resources not spent on fighting technical debt.

Next, we transitioned into the pros and cons of different dependency injection methods. The rule of thumb here is: whenever implementing manual injection, try using constructor-based DI.

Then we raised the stakes by increasing the challenge: our DI solution needed to work not just in a simple iOS app, but provide testable and maintainable injection across a modular app. We explored several techniques to accomplish this. Starting with manual injection of dependencies across modules, we found this technique to be the simplest and most reliable, though difficult to maintain. Next, we discussed introducing the DependencyProvider concept, passing it throughout the app so that relevant factories could retrieve dependencies from it. While easier to maintain, this runtime injection technique is less safe and can potentially cause crashes. If you don’t want to implement your own DependencyProvider, you can find many battle-tested third-party solutions like Swinject. We also briefly discussed compile-time injection solutions, like the brilliant Dependencies from PointFree, and explained why they might be challenging to maintain in a modular app like ours.

In addition, we explored other options for implementing dependency injection in modular apps: using SwiftUI’s Environment or a custom Property Wrapper retrieving dependencies from a singleton. While these solutions are easy to implement, low-maintenance, and reasonably secure, they’re not well-suited for non-trivial iOS apps. Especially modular ones.

For reference, you can take a look at the table below, comparing the showcased Dependency Injection methods and techniques:

Ultimately, the most important decision is ensuring you implement a Dependency Injection solution in your app. While the specific solution is essentially an implementation detail, changing course becomes increasingly difficult as your app grows in complexity. That’s why it’s worth investing time upfront to research available DI solutions and choose one that can scale with your needs. The tried-and-true DependencyProvider, despite its imperfections, remains a reliable option for my apps. What will you choose?

Don’t miss any new blogposts!

    By subscribing, you agree with our privacy policy and our terms and conditions.

    Disclaimer: All memes used in the post were made for fun and educational purposes only 😎
    They were not meant to offend anyone 🙌
    All copyrights belong to their respective owners🧐
    The thumbnail was generated with text2image ❤️ 
    Some images were generated using Leonardo AI ❤️
    Audio version generated with TTS Maker ❤️