Complete Guide to Dependency Injection in Swift

mobile-system-design swift swiftui Aug 21, 2024
Dependency Injection in SwiftUI

Dependency injection sounds complex, but it's actually a straightforward concept. Imagine a piece of code that needs another to function. Instead of creating the second piece internally, we provide it from outside. This is called "injecting" the dependency.

Dependency Injection solves many common challenges often faced in larger projects, like overlay complex initializers with numerous dependencies, difficulties in mocking data for testing, reliance on force unwrapping and many more.

Table of Contents

  1. What is Dependency Injection in Swift
  2. Types of Dependency Injection in SwiftUI
    1. Constructor Injection
    2. Environment Injection
  3. Injecting Dependency Containers Using Swinject

 

What is Dependency Injection

Dependency Injection (DI) is a design technique where an object receives its required dependencies from an external source, rather than creating them internally.

Example without Dependency Injection

// Without dependency injection
class Profile {
    let name: Name = Name(firstName: "John", lastName: "Doe")
}

In the above example, a new instance of Name is directly created.

Example with Dependency Injection

// With dependency injection
class Profile {
    let name: Name

    init(name: Name) {
        self.name = name
    }
}

Profile receives a Name object through its initializer.

 

Types of Dependency Injection in SwiftUI

There are primarily two main approaches to Dependency Injection in SwiftUI:

  1. Constructor Injection
  2. Environment Injection 

Constructor Injection

In Constructor Injection, the dependencies are passed to a view's initializer. It is also known as Initializer Injection. Let's take an example of a NetworkManager class which fetches data from a URL.

Without Dependency Injection:

class NetworkManager: ObservableObject {
    func fetchData() {
        // Code to fetch data from a hardcoded URL
        let url = URL(string: "https://example.com/api/data")!
        let session = URLSession.shared
        // ... (rest of the network fetching code)
        print("Network fetch completed..")
    }
}

The hardcoded URL in NetworkManager limits its testability and reusability. Modifying the URL requires changes to the class itself, which can potentially impact multiple usage points unnecessarily.

Let's take a simple example where we use the NetworkManager to fetch data.

struct ContentView: View {
    @StateObject var networkManager: NetworkManager = NetworkManager()

    var body: some View {
        VStack {
            Button("Fetch Data") {
                networkManager.fetchData()
            }
        }
        .padding()
    }
}

Here's how you can implement constructor dependency injection.

With Dependency Injection:

class NetworkManager: ObservableObject {
    private let urlProvider: URLProvider

    init(urlProvider: URLProvider) {
        self.urlProvider = urlProvider
    }

    func fetchData() { ... }
}

In the above code NetworkManager class relies on a URLProvider protocol to supply URLs.

protocol URLProvider {
    var url: URL { get }
}

This protocol defines a single property url of type URL. This acts as a contract, ensuring that any concrete implementation of the protocol provides a valid URL.

With this approach, the NetworkManager class is no longer directly coupled with the specifics of URL generation and it also gives you the flexibility to extend it with different URL providers.

Now, let's create the dependencies that the NetworkManager relies on, which would provide the actual production URL.

class RealURLProvider: URLProvider {
    var url: URL { return URL(string: "https://example.com/api/data")! }
}

This is how you can use it:

let realProvider = RealURLProvider()
let networkManager = NetworkManager(urlProvider: realProvider)
ContentView(networkManager: networkManager)

This creates a NetworkManager instance with a RealURLProvider, which means the fetchData call will use the production URL.

Let's make it a bit more simple by refactoring the ContentView.

extension ContentView {
    init(urlProvider: URLProvider) {
        _networkManager = StateObject(wrappedValue: NetworkManager(urlProvider: urlProvider))
    }
}

struct ContentView: View {
    @StateObject var networkManager: NetworkManager
    var body: some View { ... }
}

In the above code, the custom initializer for ContentView allows injecting a URLProvider instance. The initializer creates a StateObject of NetworkManager using the provided URLProvider.

Now, when creating a ContentView instance, you just need to provide a URLProvider to the custom initializer.

ContentView(urlProvider: RealURLProvider())

We can pass any object that confirms to URLProvider as a dependency for NetworkManager. For example,

class MockURLProvider: URLProvider {
    var url: URL { return URL(string: "https://mock.com/data")! }
}

// Usage example 
ContentView(urlProvider: MockURLProvider())

The NetworkManager instance is created using the MockURLProvider. The fetchData call will use the mock URL for testing purposes.

The constructor dependency injection approach allows switching between production and mock URLs based on the injected URLProvider implementation. It promotes cleaner code separation and reduces reliance on hardcoded URLs within the NetworkManager class.

Click here to download the complete project

Although constructor injection is beneficial, this method can lead to a chain of dependency passing in complex view hierarchies.

 

Environment Injection

This approach involves using an @EnvironmentObject property wrapper to provide dependencies to views. It allows dependencies to be shared across multiple views in the hierarchy without passing them explicitly.

Here's the implementation of the previous example using @EnvironmentObject.

struct ContentView: View {
    @EnvironmentObject var networkManager: NetworkManager

    var body: some View {
        VStack {
            Button("Fetch Data") {
                networkManager.fetchData()
            }
        }
    }
}

To make the dependency accessible, you need to add it to the environment of the ancestor view:

@main
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(NetworkManager(urlProvider: RealURLProvider()))
        }
    }
}

This NetworkManager class can be accessed from multiple views within the same hierarchy.

This method is more suitable for smaller projects or when the dependencies are relatively simple. However, it can become cumbersome when dealing with complex dependencies or when you need to mock dependencies for testing. To mitigate these challenges and enhance code maintainability, testability, and scalability, we often use Dependency Injection (DI) Containers. DI containers automate the process of managing object creation and their connections. Let's see how DI containers work.

 

Injecting Dependency Containers Using Swinject

 

What is Dependency Injection Container

DI containers are external libraries or frameworks that manage the creation and lifecycle of objects and their dependencies. To illustrate this concept further, let's see how a DI container operates with the analogy of in a restaurant kitchen.

A DI container acts as a centralized kitchen, managing and providing ingredients (dependencies) to various recipes (classes). It registers how to create and manage these ingredients, and when a recipe is requested, it assembles the required ingredients to create the final dish (object).

Before diving into implementation, let's understand its fundamental structure. A DI container typically comprises of three core components:

  • Registration: Defines how to create and manage instances of dependencies.
  • Resolution: Retrieves instances of registered dependencies from the container when requested.
  • Scope: Determines the lifecycle of a dependency instance.

 

When to use a DI Container

While SwiftUI doesn't have a built-in container, you can use third-party DI containers, if needed. However, for most SwiftUI applications the built-in mechanisms are sufficient. You might consider using a DI container in complex SwiftUI applications with a large number of dependencies and intricate dependency graphs. In such cases, a container can help manage dependencies more effectively and improve code organization.

If you decide to use a DI container, one popular option for Swift is Swinject. Swinject is a lightweight dependency injection framework for Swift that also supports features like property injection and lifecycle management. Let's learn how to use Swinject to streamline dependency management in our project.

 

Using Swinject: A Practical Guide

Swinject can be installed through Carthage, CocoaPods, or Swift Package Manager( Swinject installation)

Usage

  1. Import Swinject:
    swift import Swinject
  2. Create a container:

    let container = Container()
  3. Register Dependencies:

    container.register(URLProvider.self) { _ in RealURLProvider() }
    container.register(NetworkService.self) { resolver in
        NetworkService(urlProvider: resolver.resolve(URLProvider.self)!)
    }
    • URLProvider.self: This registers the implementation of RealURLProvider
    • NetworkService.self: This registers NetworkService and injects the URLProviderdependency resolved from the container.
  4. Resolve Dependencies:

    let networkService = container.resolve(NetworkService.self)!
    networkService.getData() // Uses the provided URL from RealURLProvider
    • container.resolve(NetworkService.self)!: Retrieves an instance of NetworkServicefrom the container, injecting the required URLProvider dependency.

 

Example: A Simple Use Case

Let's create the Container in a separate class. 

import Foundation
import Swinject

final class Injection {
    static let shared = Injection()
    var container: Container {
        get {
            return _container ?? buildContainer()
        }
        set {
            _container = newValue
        }
    }

    private var _container: Container?
    private func buildContainer() -> Container {
        let container = Container()
        container.register(URLProvider.self) { _ in RealURLProvider() }
        return container
    }
} 

In the above code:

  1. The Injection class provides a single point of access to the Swinject Container.
  2. The container property ensures that the container is created only when needed, promoting lazy initialization.
  3. The buildContainer function creates a new Container instance. By registering these dependencies, you can later resolve them from the container to use wherever required. Likewise, you can register as many global dependencies as you want.

Now, inside the NetworkManager class, instead of using the constructor injection, we can use the Injection class.

class NetworkManager: ObservableObject {
    var urlProvider: URLProvider = Injection.shared.container.resolve(URLProvider.self)!

    func fetchData() { ... }
}

This works perfectly, but doesn't look good. Let's make the syntax better.

Let's create a property wrapper for it say, Injected.

@propertyWrapper struct Injected<Dependency> {
    var wrappedValue: Dependency

    init() {
        wrappedValue = Injection.shared.container.resolve(Dependency.self)!
    }
}

The generic type Dependency allows for injecting different types of dependencies. Now, we can use the Injected property wrapper in our NetworkManager class.

class NetworkManager: ObservableObject {
    @Injected var urlProvider: URLProvider

    func fetchData() { ... }
}

This is much cleaner and easier to read.

Click here to download the complete project

Failing to register a dependency before using it will result in a runtime crash. So you don't have to worry about missing out on a dependency somewhere where you have not tested. The code will break at the run time itself.

 

Conclusion

Dependency injection is a valuable tool for building well-structured, testable, and maintainable SwiftUI applications. This article discusses how to implement dependency injection in SwiftUI using direct injection and environment objects. We also explored creating a simple DI container and using it to register and resolve dependencies, as well as creating a property wrapper for cleaner code. By decoupling view models from their dependencies, you can create more modular, flexible, and testable code. Understanding these mechanisms is crucial, and the optimal choice depends on your project's specific needs.

 

Where to go next?

By mastering Dependency Injection you've taken a significant step towards modularising your codebase. To further enhance your SwiftUI codebase, we strongly recommend delving into core mobile engineering concepts like MVVM in SwiftUI and Unit testing in SwiftUI.

Signup now to get notified about our
FREE iOS Workshops!