Image

Dependency injection (DI) is a fundamental technique for building modular, testable, and maintainable iOS applications. It allows developers to decouple objects and their dependencies, simplifying code reuse and testing. In this article, we’ll explore various DI techniques and libraries available for iOS, including SwiftUI’s Environment and EnvironmentObject, and powerful third-party libraries like Swinject.


What is Dependency Injection?

Dependency injection is a design pattern where an object’s dependencies are provided externally rather than being created internally. This approach improves flexibility and testability while reducing tight coupling.


Dependency Injection Techniques in iOS

1. Constructor Injection

Example:


// UserService abstraction
struct UserService {
    let getUserProfileName: () async throws -> String
    let changeUserProfileName: (String) async throws -> Void
}

final class UserProfileViewModel {

    // Injected at initialization
    private let userService: UserService
    init(userService: UserService) {
        self.userService = userService
    }
    
    public var userProfileName = CurrentValueSubject<String, Never>("")
    func fetchUserProfile() {
        Task {
            userProfileName.send(try await userService.getUserProfileName())
        }
    }
    func changeUserProfile(name: String) {
        Task {
            try await userService.changeUserProfileName(name)
            userProfileName.send(name)
        }
    }
}

// Usage example
static let defaultUserService = UserService(
    getUserProfileName: {
        try await Task.sleep(nanoseconds: 1_000_000_000)
        return "John"
    },
    changeUserProfileName: { _ in
        try await Task.sleep(nanoseconds: 1_000_000_000)
    }
)

static let viewModel = UserProfileViewModel(userService: defaultUserService)
  • Advantages: Explicit, safe, and immutable.
  • Disadvantages: Requires boilerplate code for dependency wiring.
  • Key Disadvantage: In a multi-level scenario some dependencies may be passed thru several levels which may not care about an specific provider.

2. Property Injection

Example:

final class UserProfileViewModel {
    var userService: UserService?
    public var userProfileName = CurrentValueSubject<String, Never>("")
    func fetchUserProfile() {
        Task {
            userProfileName.send(try await userService?.getUserProfileName() ?? "")
        }
    }
    func changeUserProfile(name: String) {
        Task {
            try await userService?.changeUserProfileName(name)
            userProfileName.send(name)
        }
    }
}

static let viewModel = {
    let viewModel = UserProfileViewModel()
    viewModel.userService = defaultUserService
    return viewModel
}()
  • Advantages: Suitable for optional or late-bound dependencies.
  • Disadvantages: Can lead to runtime errors if a dependency is not set before usage.

3. Service Locator Pattern

Example:

    class ServiceLocator {
        static let shared = ServiceLocator()
        private var services: [String: Any] = [:]
        func register<T>(_ service: T, for type: T.Type) {
            services[String(describing: type)] = service
        }

        func resolve<T>(_ type: T.Type) -> T {
            return services[String(describing: type)] as! T
        }
    }

    final class UserProfileViewModel {
        private lazy var userService: UserService = {
            ServiceLocator.shared.resolve(UserService.self)
        }()
        public var userProfileName = CurrentValueSubject<String, Never>("")
        func fetchUserProfile() {
            Task {
                userProfileName.send(try await userService.getUserProfileName())
            }
        }
        func changeUserProfile(name: String) {
            Task {
                try await userService.changeUserProfileName(name)
                userProfileName.send(name)
            }
        }
    }

    static let viewModel = UserProfileViewModel()
  • Advantages: Centralized management of dependencies.
  • Disadvantages: Encourages tight coupling and can complicate testing.

4. SwiftUI’s Environment and EnvironmentObject

Example: Using Environment

// Define a custom EnvironmentKey
struct UserServiceKey: EnvironmentKey {
    static let defaultValue: UserService = SwiftUIInjection.defaultUserService
}

// Extend EnvironmentValues
extension EnvironmentValues {
    var userService: UserService {
        get { self[UserServiceKey.self] }
        set { self[UserServiceKey.self] = newValue }
    }
}

struct UserProfileView: View {
    @Environment(\.userService) private var userService
    @State var userProfileName: String = ""

    var body: some View {
        Button("Fetch User Profile") {
            Task {
                userProfileName = try await userService.getUserProfileName()
            }
        }
    }
}
  • Advantages: Seamlessly integrates with SwiftUI’s view hierarchy.
  • Key Advantage: Tree + multi-level injection, natural to view hierarchies.
  • Disadvantages: Limited to SwiftUI projects.

5. Third-Party Dependency Injection Libraries

In addition to the built-in Swift features, several third-party libraries can help manage dependency injection more efficiently:

  • Swinject is a lightweight, easy-to-use dependency injection framework for Swift.
  • Resolver is another DI library for Swift that provides powerful resolution mechanisms and SwiftUI support.
  • Factory focuses on simplicity and testability for DI, making it easy to mock and resolve dependencies.

We believe there’s still room for a new dependency injection library that adopts a multi-level tree structure inspired by SwiftUI’s approach but decouples dependency injection from the app’s views. In our projects, we’ve implemented a similar solution to enable this kind of flexibility, and we’re excited to explore this approach in future articles. Stay tuned!

  • Advantages: Flexible, supports dependency graph resolution. Community supported
  • Disadvantages: Adds library overhead. No tree-multilevel design like SwiftUI’s Environment.

Choosing the Right Approach

The best dependency injection method or flavour depends on your project requirements:

  • Use Constructor Injection for maximum clarity and immutability.
  • Use SwiftUI’s Environment for projects built with SwiftUI.
  • Use Swinject or Resolver if you need robust DI support with advanced features like dependency graphs or SwiftUI integration.

Conclusion

Dependency injection is a key practice for building modular and testable iOS applications. Swift offers a range of built-in solutions like Environment and EnvironmentObject, while third-party libraries like Swinject, Resolver, and Factory provide additional flexibility and features.

By understanding these options, you can select the DI method that best fits your project’s needs and enhance your app’s scalability and maintainability.