Say Goodbye to Dependency Over-Injection in ViewModels

Tushar Sharma
3 min readDec 14, 2024

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:

  1. Fetching a list of products.
  2. Tracking user interactions for analytics.
  3. 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

  1. Simplified Initializer: The ViewModel now takes a single factory instead of multiple dependencies.
  2. Single Responsibility: Each use case encapsulates one responsibility, keeping logic modular and maintainable.
  3. Reusability: Use cases can be reused across different ViewModels.
  4. Centralized Creation Logic: The composer ensures dependencies are created in one place, improving clarity and consistency.
  5. Testability: Mocking the factory or individual use cases is straightforward, enabling isolated tests.
  6. 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!

Sign up to discover human stories that deepen your understanding of the world.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Responses (9)

Write a response