This week, let’s explore dependency injection - a concept many engineers with a few years of experience likely use regularly, especially when working with design systems. However, if someone asked us to explain dependency injection in detail, we might find hesitate. It’s also a common topic in technical interviews, making it even more important to understand deeply.
In this article, we’ll revisit what dependency injection is and also look at a practical example in a UIKit-based iOS app to see how DI works in real-world scenarios.

What is Dependency Injection (DI)?

Dependency Injection (DI) is a design pattern that helps achieve Inversion of Control (IoC) — a way to let an object get its dependencies from outside instead of creating them itself.

For example, instead of a class instantiating its required dependencies, they are “injected” into the class, usually via:

  • Constructor injection
  • Property injection
  • Method injection

These approaches vary in how dependencies are passed to the class, depending on the structure of your code and use case.

What is Dependency Inversion (DI Principle)?

The Dependency Inversion Principle (part of the SOLID principles) is a design principle that states:

  1. High-level modules (e.g., business logic) should not depend on low-level modules (e.g., data access). Both should depend on abstractions.
  2. Abstractions (e.g., interfaces) should not depend on details. Details should depend on abstractions.

This principle decouples high-level logic from the details of how dependencies are implemented, improving flexibility and scalability.

Example: Without Dependency Inversion:

class UserService {
    let apiClient = APIClient() // Direct dependency
}

With Dependency Inversion:

protocol APIClientProtocol {
    func fetchData()
}

class UserService {
    let apiClient: APIClientProtocol

    init(apiClient: APIClientProtocol) {
        self.apiClient = apiClient // Dependency is injected via abstraction
    }
}

Benefits of Dependency Injection

Dependency Injection offers several advantages in software development:

  • Loose Coupling
  • Improved Testability
  • Flexibility and Scalability
  • Enhanced Readability and Maintainance
  • Separation of Concerns
  • Better Reusability

Now that we’ve covered the fundamentals of Dependency Injection, let’s dive into a real-world example to see how we can implement DI in a UIKit-based iOS app.

From Theory to Practice

Let’s pull this repository to use as a project starter.
https://github.com/hoangnhat92/pulsebeat-uikit-starter/

PulseBeat is a music app that allows users to save their favorite songs locally, enabling both online and offline access.

Before diving in, let’s explore the app’s architecture and folder structure. PulseBeat is designed using the MVVM-C (Model-View-ViewModel-Coordinator) pattern. While you may already be familiar with MVVM, if this is your first time encountering the Coordinator pattern, I recommend reading this article from HackingwithSwift.

In short, the Coordinator pattern encapsulates navigation logic, making it reusable and testable.
Below is an overview of the folder structure:

App/
├── Coordinators/
│   ├── AppCoordinator.swift
│   └── MainCoordinator.swift
├── Dependencies/
│   ├── AdService.swift
│   ├── AnalyticsService.swift
│   ├── CacheService.swift
│   └── ...
├── Views/
│   ├── LoginViewController.swift
│   ├── FavoriteViewController.swift
│   ├── HomeViewController.swift
│   └── SettingsViewController.swift
├── ViewModels/
│   ├── LoginViewModel.swift
│   ├── FavoriteViewModel.swift
│   ├── HomeViewModel.swift
│   └── SettingsViewModel.swift
├── Services/
│   └── LocalStorage.swift
|── AppDelegate.swift
└── MyApp/
  • Coordinators: Manages how the app navigates between screens, keeping it organized and reusable.
  • Dependencies: External components or resources that a piece of code relies on, which must be included in other classes for proper functionality.
  • Views: Handles what the user sees and interacts with on each screen.
  • ViewModels: Connects the app’s data and logic to the user interface for each screen.
  • Services: Manages key functionalities like data storage and connections to external services.

Identyfing the problems

Let’s take a moment to assess what’s going wrong with the current app. When you run it, the app prompts you to log in at the start. Interestingly, you can use any username and password to bypass the login screen and land directly on the home screen, which has a tab bar.
Sounds familiar, right? Many apps follow a similar structure. But now, let’s dive into the code to see what’s really happening.

The coordinator pattern is in place, encapsulating the navigation logic. This is good practice, as it keeps the initialization of the tab bar and other main components centralized in the MainCoordinator.
Here’s the code for the MainCoordinator:

let tabBarController = UITabBarController()
        
let homeViewModel = HomeViewModel()
let favoriteViewModel = FavoriteViewModel()
let settingsViewModel = SettingsViewModel()

let homeVC = HomeViewController(viewModel: homeViewModel)
homeVC.tabBarItem = UITabBarItem(
    title: "Home",
    image: UIImage(systemName: "house"),
    selectedImage: UIImage(systemName: "house.fill")
)
let homeNavController = UINavigationController(
    rootViewController: homeVC
)
    
let favoriteVC = FavoriteViewController(viewModel: favoriteViewModel)
    favoriteVC.tabBarItem = UITabBarItem(
        title: "Favorites",
        image: UIImage(systemName: "star"),
        selectedImage: UIImage(systemName: "star.fill")
)
let favoriteNavController = UINavigationController(
    rootViewController: favoriteVC
)


let settingsVC = SettingsViewController(viewModel: settingsViewModel)
settingsViewModel.delegate = self
settingsVC.tabBarItem = UITabBarItem(
    title: "Settings",
    image: UIImage(systemName: "gearshape"),
    selectedImage: UIImage(systemName: "gearshape.fill")
)
let settingsNavController = UINavigationController(
    rootViewController: settingsVC
)

tabBarController.viewControllers = [
    homeNavController,
    favoriteNavController,
    settingsNavController
]
navigationController.setViewControllers([tabBarController], animated: true)
navigationController.isNavigationBarHidden = true

At first look, the ViewControllers and ViewModels seem to follow best practices. Nothing stands out as immediately problematic. But let’s take a closer look, specifically at HomeViewModel.

Here’s a snippet from HomeViewModel:

func fetchLatestSongs() {
    NetworkingService.shared.fetchLatestSongs { [weak self] result in
        switch result {
        case .success(let songs):
            self?.songs = songs
            self?.onSongsUpdated?()
        case .failure(let error):
            print(error)
        }
    }
}

And here are some internal initializations

private let analyticsService = AnalyticsService()
private let adService = AdService()    

At first, this might seem fine. However, there’s an issue: this approach violates the principles of dependency inversion. By tightly coupling these dependencies within the class, the code becomes less testable and lacks the flexibility to accommodate future changes.

For example, imagine you want to use cached data instead of making network requests when the device is offline. With the current implementation, that’s not possible. The dependencies are hardcoded within the HomeViewModel, making it impossible to swap out implementations or introduce new behaviors. Additionally, testing becomes a challenge. There’s no way to pass mock objects for AnalyticsService or AdService, which limits your ability to simulate test-specific behaviors or scenarios in HomeViewModel.

How to fix it

There are many approaches to resolve dependency injection, but let’s go with one of the most common: constructor injection.

Let’s update our HomeViewModel like this:

class HomeViewModel {
    
    private let analyticsService: AnalyticsService
    private let adService: AdService
    private let networkingService: NetworkingService
    
    init(networkingService: NetworkingService,
         analyticsService: AnalyticsService,
         adService: AdService) {
        self.networkingService = networkingService
        self.analyticsService = analyticsService
        self.adService = adService
    }

    ...
}

As you can see, all dependencies are passed through the constructor, eliminating singletons and internal initialization. This ensures that the caller has full control over managing these dependencies.

Similarly, we can refactor other ViewModels for consistency:

FavoriteViewModel

class FavoriteViewModel {
    
    private let cacheService: CacheService
    private let analyticsService: AnalyticsService
    private let storage: LocalStorage
    
    private let fileManager: FileManager
    private let userDefaults: UserDefaults
    private let bundle: Bundle
    
    init(cacheService: CacheService,
         analyticsService: AnalyticsService,
         storage: LocalStorage,
         fileManager: FileManager = FileManager.default,
         userDefaults: UserDefaults = UserDefaults.standard,
         bundle: Bundle = Bundle.main
    ) {
        self.cacheService = cacheService
        self.analyticsService = analyticsService
        self.storage = storage
        self.fileManager = fileManager
        self.userDefaults = userDefaults
        self.bundle = bundle
    }
    ...
}

By leveraging Swift’s default initializations, such as FileManager.default and UserDefaults.standard, the initialization process becomes simpler and more convenient.

SettingsViewModel

class SettingsViewModel {
    
    private let userService: UserService
    private let analyticsService: AnalyticsService    
    
    init(userService: UserService,
         analyticsService: AnalyticsService
    ) {
        self.userService = userService
        self.analyticsService = analyticsService
    }

    ...
}

With these changes, all ViewModels now follow the constructor injection pattern, making them more flexible and testable. This way, you can easily pass mock dependencies during testing or swap out implementations without modifying the core logic.

Once we’ve updated the ViewModels, let’s return to the MainCoordinator to pass the necessary dependencies required for initializing them.

let analyticsService = AnalyticsService()
let networkingService = NetworkingService()
let adService = AdService()
let cacheService = CacheService()
let storage = LocalStorage()
let userService = UserService(networkingService: networkingService)

let homeViewModel = HomeViewModel(
    networkingService: networkingService,
    analyticsService: analyticsService,
    adService: adService
)

let favoriteViewModel = FavoriteViewModel(
    cacheService: cacheService,
    analyticsService: analyticsService,
    storage: storage
)

let settingsViewModel = SettingsViewModel(
    userService: userService,
    analyticsService: analyticsService
)

// The rest of the logic stays as is

Running your first test

One of the key benefits of dependency injection is making the code more testable. Let’s dive into a sample test for HomeViewModel.

Here’s the HomeViewModelTest using the Swift testing framework:

import Testing
struct HomeViewModelTest {
    .... 

    class MockNetworkingService: NetworkingService {
        override func fetchLatestSongs(
            completion: @escaping(Result<[String], Error>) -> Void
        ) {
            completion(.success(["Song 1"]))
        }
    }
    
    // Test variables
    var sut: HomeViewModel!
    var mockAdService: MockAdService!
    var mockAnalyticsService: MockAnalyticsService!
    var mockNetworkingService: MockNetworkingService!
      
    init() {
        mockAdService = MockAdService()
        mockAnalyticsService = MockAnalyticsService()
        mockNetworkingService = MockNetworkingService()
        
        sut = HomeViewModel(
            networkingService: mockNetworkingService,
            analyticsService: mockAnalyticsService,
            adService: mockAdService
        )
    }

    @Test func testFetchLatestSongs() async throws {            
        sut.fetchLatestSongs()
                    
        #expect(sut.songs.count == 1)
        #expect(sut.songs.first == "Song 1")
    }
}

Thanks to constructor injection, we can pass a mockNetworkService to HomeViewModel, allowing us to control its behavior and return the expected list of songs for our test.

When you run the test, it should pass successfully. This approach can also be applied to other dependencies, ensuring consistent, reliable testing across the project.

Alright, we’re wrapping up here! But have you noticed how tedious manual dependency injection can get? Just look at our MainCoordinator — it’s full of boilerplate code to set up and manage dependencies. Curious how DI frameworks solve this? Let’s check that out in the next section!

Checking out Third-Party Libraries for Dependency Injection

While constructor injection is a common and straightforward way to implement dependency injection, third-party libraries can further simplify the process and add flexibility. One popular option in Swift is Swinject, a lightweight dependency injection framework.

What is Swinject?

Swinject is a third-party library that provides a container-based approach for managing dependencies. With Swinject, you can register services and resolve them when needed, reducing boilerplate code and centralizing dependency management.

Features of Swinject
Swinject comes with a rich set of features that make dependency injection seamless and powerful:

  • Pure Swift Type Support
  • Injection with Arguments
  • Initializer/Property/Method Injections
  • Initialization Callback
  • Circular Dependency Injection
  • Object Scopes
  • Container Hierarchy

Using Swinject

Here’s a simple example of how to use Swinject in a project:

Register Dependencies

import Swinject

let container = Container()

container.register(NetworkingServiceProtocol.self) { _ in
    NetworkingService()
}

container.register(AnalyticsServiceProtocol.self) { _ in
    AnalyticsService()
}

container.register(HomeViewModel.self) { resolver in
    let networkingService = resolver.resolve(NetworkingServiceProtocol.self)!
    let analyticsService = resolver.resolve(AnalyticsServiceProtocol.self)!
    return HomeViewModel(networkingService: networkingService, analyticsService: analyticsService)
}

Resolve Dependencies

let homeViewModel = container.resolve(HomeViewModel.self)!
homeViewModel.fetchLatestSongs()

It’s really straightforward, right? Basically, you set up all your dependencies in one place and use them wherever you need. I also added some service protocols to keep things flexible and organized, so other services can fit in easily.

Also, take note of how dependencies are resolved using explicit force unwrapping, so if something isn’t registered correctly, the app will crash since Swinject handles everything at runtime.

Organizing Dependencies with Assembly

Swinject makes it super easy to set up and resolve dependencies at runtime. However, if we cram all the dependency registration logic into one place, things can quickly spiral out of control as the app grows.
Thankfully, Swinject comes with a fantastic feature called Assembly, which lets you logically group your dependencies, keeping everything organized and easier to manage.

Here’s how to use it:

Step 1 : Create Assemblies for Different Modules

Start by creating assemblies for each module of your app. Think of them as little organizers for your dependencies. For example:

class NetworkingAssembly: Assembly {
    func assemble(container: Container) {
        container.register(NetworkingServiceProtocol.self) { _ in
            NetworkingService()
        }
    }
}
class AnalyticsAssembly: Assembly {
    func assemble(container: Container) {
        container.register(AnalyticsServiceProtocol.self) { _ in
            AnalyticsService()
        }.inObjectScope(.container)
    }
}

Step 2: Assemble the Container

Once you’ve set up your assemblies, it’s time to combine them and build your dependency container:

let container = Container()
let assemblies: [Assembly] = [
    NetworkingAssembly(),
    AnalyticsAssembly(),
    ViewModelAssembly()
]
assemblies.forEach { $0.assemble(container: container) }

Here’s how MainCoordinator can initialize services and view models using the container:

let networkingService = container.resolve(NetworkingServiceProtocol.self)!        
let analyticsService = container.resolve(AnalyticsServiceProtocol.self)!
let adService = container.resolve(AdServiceProtocol.self)!
let cacheService = container.resolve(CacheServiceProtocol.self)!
let storage = container.resolve(LocalStorage.self)!

let tabBarController = UITabBarController()
        
let userService = UserService(networkingService: networkingService)

let homeViewModel = HomeViewModel(
    networkingService: networkingService,
    analyticsService: analyticsService,
    adService: adService
)

let favoriteViewModel = FavoriteViewModel(
    cacheService: cacheService,
    analyticsService: analyticsService,
    storage: storage
)

let settingsViewModel = SettingsViewModel(
    userService: userService,
    analyticsService: analyticsService
)

Step 3: Scoping the Objects

In Swinject, a scope determines the lifecycle of the dependencies (services) registered in a container. Swinject provides several scope options, including Transient, Graph (default), Container, and Weak, each catering to different use cases.

Choosing the Right Scope

When selecting a scope for your service, consider:

  • State: Does the service maintain state that should persist?
  • Lifetime: How long do you need the instance to exist?
  • Performance: Recreating large objects frequently can impact performance.

Want to see the whole thing in action? Check out this full example on GitHub.

Swinject: The Good and the Not-So-Good

Swinject has been a go-to for dependency injection in UIKit projects for years, but it’s not without its challenges:

  • Dependencies are resolved at runtime, which can lead to crashes if a dependency is not registered.
  • It does not support async/await, making asynchronous initialization unavailable.
  • The project has seen less traction and fewer updates in recent years.

Looking for Alternatives?

If Swinject doesn’t meet your needs, here are a few other options worth exploring:

  • Resolver: A lightweight alternative, though the author has deprecated it and introduced a new concept of Factory.
  • Needle: A robust dependency injection library from Uber’s team, designed with performance and scalability in mind.
  • Typhoon: Another popular DI framework for Swift and Objective-C, with a strong feature set for larger projects.

Conclusion

That was quite a long article, but I hope it was worth your time! Dependency injection is widely used across all types of projects. If you’re just starting out, try incorporating it into a small project. It’s always a good idea to begin with constructor injection, it’s simple, effective, and doesn’t require a 3rd-party framework until you truly need one.

I’m confident that the time and effort you invest in mastering dependency injection will pay off in the long run. I’ll be writing another article soon about how dependency injection works in SwiftUI, so stay tuned!
Happy reading!

Like what you're reading?

If this article hit the spot, why not subscribe to the weekly newsletter?
Every weekend, you'll get a short and sweet summary of the latest posts and tips—free and easy, no strings attached!
You can unsubscribe anytime.