Let's say I have a view model called AuthViewModel
which handles all authentication-related activities and states of my SwiftUI app, and it requires the following dependencies:
- A HTTPClient object to run HTTP requests (this is an abstraction over the
URLSession
logic) - A SecureStorage object to abstract keychain access logic for secrets
- A CoreDataStorage object to abstract Core Data access logic
These classes should also ideally be passed down the view hierarchy so that other view models could reuse them too.
The problem is I've been trying different ways to inject these dependencies that are required in the init method of my AuthViewModel
without success. You can imagine the init method looks like this:
class AuthViewModel: ObservableObject {
@Published private(set) var authToken: String?
init(httpClient: HTTPClient, secureStorage: SecureStorage, coreDataStorage: CoreDataStorage) {
}
}
Here are a few things that I have tried and/or found unsatisfactory:
1. Turn all the dependencies listed above into ObservableObject
s and use .environmentObject
to pass them down the view hierarchy
This doesn't work because if I pluck off the @EnvironmentObject
in the same View
body that defines the authViewModel
, the init method of the view model doesn't have access to self
, and hence the rest of the environmentally-injected dependencies.
struct LoginView: View {
@EnvironmentObject private var httpClient: HTTPClient
@EnvironmentObject private var secureStorage: SecureStorage
@EnvironmentObject private var coreDataStorage: CoreDataStorage
// This won't work because at the current scope `self` is not ready
@StateObject private var authViewModel = AuthViewModel(
httpClient: httpClient,
secureStorage: secureStorage,
coreDataStorage: coreDataStorage
)
}
2. Remove dependencies from the initializer and move them into settable properties
This is a plausible workaround but it generates too much work unwrapping the optional dependencies for its internal methods, not to mention that these dependencies are required for the functioning of the view model and should really have non-null values at all times. In a large application, we must also ensure that other developers do not forget to set the dependencies—if only there was a failsafe way of helping them not forget about required dependencies am I right?
3. Make the dependencies private environment properties within the view model itself
This defeats the purpose of Dependency Injection, because it's turning our view model from a blackbox into a whitebox object, meaning that we cannot successfully operate this object without explicit knowledge of its internals, aka the private environment dependencies inside of it. This complicates testing and requires the developer to always look inside of the private properties of the view model to make use of it properly.
Also, this is almost akin to using singletons of dependencies, which is the very thing we are trying to avoid here. At least, I'd like these singletons to be in the View
body, but once we cross over into the view model's territory I'd like them to be injected at the very least.
4. Inject these dependencies at the top level of the application where the dependencies are defined:
There were suggestions floating around to define these outside the scope of the @main
struct.
private let httpClient = ...
private let secureStorage = ...
private let coreDataStorage = ...
private let authViewModel = AuthViewModel(httpClient: httpClient, secureStorage: secureStorage, coreDataStorage: coreDataStorage)
@main
struct MyApp: App {
@StateObject private var myAuthViewModel = authViewModel
var body: some Scene {
WindowScene {
SplashScreen()
.environmentObject(myAuthViewModel)
}
}
}
This unconventional approach has one downside: I don't just have this one view model in my app. There might be other view models down the line that requires the dependencies too, and there's no initializing them like this then.
To solve this, I am seeking a solution that:
- Allows me to define a few dependencies at the
@main
struct of my app and pass them down such that other view models can be initialized with them - Does not sacrifice the testability of the view model, and maintains its decoupled nature
- Allow multiple instances of the same dependency to co-exist. For example, I might want to have different
HTTPClient
s for API requests and for downloading/uploading operations
PS: At the time of writing Xcode 13 is still in beta, but if there is a solution that requires Swift 5.5 I'm all ears. I'm using it anyway.