Say Goodbye to Dependency Over-Injection in ViewModels
As your app grows, so do the dependencies your ViewModels require. Injecting multiple dependencies directly into a ViewModel’s initializer may seem like the right approach initially, but this can quickly lead to bloated constructors and tangled code. In this article, I’ll walk you through a practical and elegant way to prevent dependency over-injection using use cases, a factory, and a composer.
The Problem with Over-Injection
Consider a ProductListViewModel
tasked with:
- Fetching a list of products.
- Tracking user interactions for analytics.
- Managing network retries and loading indicators.
Here’s how it might look in a typical implementation:
class ProductListViewModel {
private let productService: ProductService
private let analyticsService: AnalyticsService
private let networkManager: NetworkManager
private let retryManager: RetryManager
init(productService: ProductService, analyticsService: AnalyticsService, networkManager: NetworkManager, retryManager: RetryManager) {
self.productService = productService
self.analyticsService = analyticsService
self.networkManager = networkManager
self.retryManager = retryManager
}
func fetchProducts() {
productService.fetchProducts()
}
func trackUserInteraction() {
analyticsService.logEvent("User tapped button")
}
func retryAction() {
retryManager.retry()
}
}
At first, this approach looks clean, but as the ViewModel’s responsibilities grow, the initializer quickly becomes a dumping ground for dependencies. This makes the code harder to maintain and less testable.
A Better Approach: Modular Dependencies Using Use Cases, Factory, and Composer
To simplify the design, we’ll break down the logic into use cases and inject a factory instead of directly injecting services. We’ll also use a composer to centralize the creation of these use cases.
What Is a Composer?
A composer is a class or struct responsible for composing and providing dependencies to other parts of the application. It encapsulates the logic for creating objects, ensuring all required dependencies are properly initialized and injected.
In our example, the composer will:
- Conform to a factory protocol that defines the required methods.
- Create and provide use cases for the
ProductListViewModel
.
By using a composer, we centralize the construction logic, keeping the ViewModel and other parts of the app clean and focused.
Step 1: Define Use Case Protocols
Start by defining protocols for the specific responsibilities (use cases) of the ViewModel:
protocol ProductUseCase {
func fetchProducts()
}
protocol AnalyticsUseCase {
func trackUserInteraction(event: String)
}
protocol NetworkUseCase {
func retryAction()
}
Step 2: Implement the Use Cases
Create concrete implementations for each use case. These classes encapsulate their respective logic and dependencies.
class ProductUseCaseImpl: ProductUseCase {
private let productService: ProductService
init(productService: ProductService) {
self.productService = productService
}
func fetchProducts() {
productService.fetchProducts()
}
}
class AnalyticsUseCaseImpl: AnalyticsUseCase {
private let analyticsService: AnalyticsService
init(analyticsService: AnalyticsService) {
self.analyticsService = analyticsService
}
func trackUserInteraction(event: String) {
analyticsService.logEvent(event)
}
}
class NetworkUseCaseImpl: NetworkUseCase {
private let retryManager: RetryManager
init(retryManager: RetryManager) {
self.retryManager = retryManager
}
func retryAction() {
retryManager.retry()
}
}
Step 3: Create a Factory Protocol
To abstract the creation of use cases, define a factory protocol:
protocol ProductListViewModelFactory {
func makeProductUseCase() -> ProductUseCase
func makeAnalyticsUseCase() -> AnalyticsUseCase
func makeNetworkUseCase() -> NetworkUseCase
}
Step 4: Implement the Factory with a Composer
The composer will conform to the factory protocol and handle the creation of use cases.
class ProductListViewModelComposer: ProductListViewModelFactory {
func makeProductUseCase() -> ProductUseCase {
return ProductUseCaseImpl(productService: ProductServiceImpl())
}
func makeAnalyticsUseCase() -> AnalyticsUseCase {
return AnalyticsUseCaseImpl(analyticsService: AnalyticsServiceImpl())
}
func makeNetworkUseCase() -> NetworkUseCase {
return NetworkUseCaseImpl(retryManager: RetryManagerImpl())
}
}
The ProductListViewModelComposer
acts as a central hub for dependency creation and provides the required objects when needed.
Step 5: Refactor the ViewModel
The ProductListViewModel
now depends on the factory to provide its use cases, making its initializer clean and future-proof.
class ProductListViewModel {
private let productUseCase: ProductUseCase
private let analyticsUseCase: AnalyticsUseCase
private let networkUseCase: NetworkUseCase
init(factory: ProductListViewModelFactory) {
self.productUseCase = factory.makeProductUseCase()
self.analyticsUseCase = factory.makeAnalyticsUseCase()
self.networkUseCase = factory.makeNetworkUseCase()
}
func fetchProducts() {
productUseCase.fetchProducts()
}
func trackInteraction() {
analyticsUseCase.trackUserInteraction(event: "User tapped button")
}
func retryAction() {
networkUseCase.retryAction()
}
}
Step 6: Initialize the ViewModel
Here’s how to use the composer to create the ViewModel:
let factory = ProductListViewModelComposer()
let viewModel = ProductListViewModel(factory: factory)
viewModel.fetchProducts()
viewModel.trackInteraction()
viewModel.retryAction()
Why This Approach Works
- Simplified Initializer: The ViewModel now takes a single factory instead of multiple dependencies.
- Single Responsibility: Each use case encapsulates one responsibility, keeping logic modular and maintainable.
- Reusability: Use cases can be reused across different ViewModels.
- Centralized Creation Logic: The composer ensures dependencies are created in one place, improving clarity and consistency.
- Testability: Mocking the factory or individual use cases is straightforward, enabling isolated tests.
- Scalability: Adding new functionality is as simple as creating a new use case and updating the factory.
Conclusion
Dependency over-injection can make your ViewModels hard to manage and scale. By introducing use cases, a factory protocol, and a composer, you create a modular, testable, and scalable architecture that’s easy to extend.
If you’ve encountered dependency over-injection in your projects, try this approach and see the difference. Clean, maintainable code is just a few refactors away!