Swift and SwiftUI tutorials for Swift Developers

Dependency Injection in SwiftUI

If you are an iOS Developer who has migrated from UIKit to SwiftUI, or if you are starting your professional career in Swift programming, you have likely encountered a term that sounds intimidating but is the fundamental pillar of scalable software architecture: Dependency Injection (DI).

In the current Xcode ecosystem, writing code that simply “works” is no longer enough. The market demands robust, testable, modular, and maintainable applications. This is where mastering Dependency Injection in SwiftUI becomes your strongest competitive advantage.

In this comprehensive tutorial, we will break down what it is, why you desperately need it if you want to level up, and most importantly: how to implement it correctly in your apps for iOS, macOS, and watchOS using pure Swift.

What is Dependency Injection and Why Should an iOS Developer Use It?

Before opening Xcode and starting to type, let’s define the concept. Simply put, Dependency Injection is a design pattern that allows an object to receive other objects (its dependencies) that it needs to function, rather than creating them internally.

To better understand it, let’s analyze the common problem many developers face at the beginning: Tight Coupling.

The Problem: Tight Coupling in Swift

If you are an iOS Developer who has migrated from UIKit to SwiftUI, or if you are starting your professional career in Swift programming, you have likely encountered a term that sounds intimidating but is the fundamental pillar of scalable software architecture: Dependency Injection (DI).

In the current Xcode ecosystem, writing code that simply “works” is no longer enough. The market demands robust, testable, modular, and maintainable applications. This is where mastering Dependency Injection in SwiftUI becomes your strongest competitive advantage.

In this comprehensive tutorial, we will break down what it is, why you desperately need it if you want to level up, and most importantly: how to implement it correctly in your apps for iOS, macOS, and watchOS using pure Swift.

What is Dependency Injection and Why Should an iOS Developer Use It?

Before opening Xcode and starting to type, let’s define the concept. Simply put, Dependency Injection is a design pattern that allows an object to receive other objects (its dependencies) that it needs to function, rather than creating them internally.

To better understand it, let’s analyze the common problem many developers face at the beginning: Tight Coupling.

The Problem: Tight Coupling in Swift

Imagine you are building a user profile screen that needs to download data from the internet. A novice approach would be to instantiate the networking class directly inside the View or the ViewModel. This creates a rigid dependency.

Let’s look at an example of what NOT to do:

// BAD PRACTICE: Tight Coupling
// This code makes it impossible to test the ViewModel without making real network calls.

import SwiftUI

class NetworkManager {
    func fetchUserData() -> String {
        return "User data from server"
    }
}

class UserViewModel: ObservableObject {
    // The ViewModel creates its own dependency. It is hard to replace.
    let apiService = NetworkManager() 
    
    @Published var userName: String = ""
    
    func fetchUser() {
        self.userName = apiService.fetchUserData()
    }
}

Why is this code harmful to an iOS Developer?

  • Impossibility of Testing: If you want to write unit tests for UserViewModel, you cannot do so without running the real NetworkManager. This means your tests will depend on the internet connection, be slow, and fail if the server goes down.
  • Code Rigidity: If tomorrow you want to swap NetworkManager for a local database for offline mode, you will have to rewrite the entire ViewModel.

The Solution: Decoupling via Injection

Modern Swift programming favors decoupling. Instead of the ViewModel creating the service, we “inject” it from the outside. This inverts control and makes our code modular.

// GOOD PRACTICE: Dependency Injection
// Define a contract (Protocol)
protocol DataServiceProtocol {
    func fetchUserData() -> String
}

class UserViewModel: ObservableObject {
    // The ViewModel doesn't know what class it is, only that it conforms to the protocol
    let apiService: DataServiceProtocol 
    
    @Published var userName: String = ""

    // Injection via Initializer (Constructor Injection)
    init(apiService: DataServiceProtocol) {
        self.apiService = apiService
    }
    
    func fetchUser() {
        self.userName = apiService.fetchUserData()
    }
}

Now, the UserViewModel is agnostic. It doesn’t know if the data comes from Firebase, a REST API, or a local JSON file. It only knows that something conforms to the DataServiceProtocol.

Dependency Injection Strategies in SwiftUI

SwiftUI is a declarative framework, which slightly changes the rules compared to the old UIKit. Below, we will explore the three main ways to apply Dependency Injection in SwiftUI to build professional apps in Xcode.

1. Constructor Injection

This is the purest, safest, and most recommended form of DI. It ensures that a view or a ViewModel has everything it needs before being created. If a dependency is missing, the Swift compiler will throw an error, preventing runtime crashes.

Let’s implement a complete user list system.

Step 1: Define the Protocol and Services
First, we abstract our dependency. This is crucial for protocol-oriented Swift programming.

import Foundation

// 1. The Contract
protocol UserDataService {
    func getUsers() async throws -> [String]
}

// 2. Real Service (Production) - Calls an API
class NetworkDataService: UserDataService {
    func getUsers() async throws -> [String] {
        // Asynchronous internet call simulation
        try await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 second
        return ["Ana Garcia", "Carlos Perez", "Sofia M."]
    }
}

// 3. Mock Service (Tests / Previews) - Instant fake data
class MockDataService: UserDataService {
    func getUsers() async throws -> [String] {
        return ["Test User 1", "Test User 2", "Test User 3"]
    }
}

Step 2: The Injectable ViewModel
The ViewModel must receive something that conforms to UserDataService in its initializer.

import SwiftUI

@MainActor
class UserListViewModel: ObservableObject {
    @Published var users: [String] = []
    
    // Private and immutable dependency
    private let dataService: UserDataService 
    
    // INJECTION HAPPENS HERE
    init(service: UserDataService) {
        self.dataService = service
    }
    
    func loadData() {
        Task {
            do {
                let fetchedUsers = try await dataService.getUsers()
                self.users = fetchedUsers
            } catch {
                print("Error loading users: \(error)")
            }
        }
    }
}

Step 3: The View in SwiftUI
Finally, we build the view. Observe how we instantiate the StateObject using the custom initializer.

struct UserListView: View {
    // We use StateObject to maintain the ViewModel lifecycle
    @StateObject private var viewModel: UserListViewModel
    
    // Injection into the View
    init(service: UserDataService) {
        _viewModel = StateObject(wrappedValue: UserListViewModel(service: service))
    }
    
    var body: some View {
        NavigationStack {
            List(viewModel.users, id: \.self) { user in
                Text(user)
                    .font(.headline)
            }
            .navigationTitle("Users")
            .onAppear {
                viewModel.loadData()
            }
        }
    }
}

The Power of Previews in Xcode
Thanks to DI, our Previews are instant and do not rely on the internet. We simply inject the MockDataService.

struct UserListView_Previews: PreviewProvider {
    static var previews: some View {
        // We inject the Mock, not the real service
        UserListView(service: MockDataService())
    }
}

2. EnvironmentObject: The “Magic” Injection of SwiftUI

Sometimes, passing dependencies through initializers can become tedious if you have a very deep view hierarchy (a problem known as “Prop Drilling”). SwiftUI offers an elegant solution for global dependencies: @EnvironmentObject.

Imagine an authentication manager that the entire app needs to know about.

class AuthenticationService: ObservableObject {
    @Published var isAuthenticated = false
    
    func login() {
        isAuthenticated = true
    }
    
    func logout() {
        isAuthenticated = false
    }
}

In your main App file (the entry point in Xcode), you inject the dependency into the application environment.

@main
struct MySwiftApp: App {
    // Create the instance here (Source of Truth)
    @StateObject private var authService = AuthenticationService()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(authService) // Inject into the environment globally
        }
    }
}

Now, any child, grandchild, or great-grandchild view can access this without passing it through the constructor, simply by declaring that it expects an object from the environment.

struct ProfileView: View {
    // SwiftUI automatically looks for this in the environment
    @EnvironmentObject var authService: AuthenticationService
    
    var body: some View {
        VStack {
            if authService.isAuthenticated {
                Text("Welcome, iOS Developer")
                Button("Log Out") {
                    authService.logout()
                }
            } else {
                Text("Please identify yourself")
                Button("Log In") {
                    authService.login()
                }
            }
        }
    }
}

⚠️ Warning: Although convenient, if you forget to inject the object at the root with .environmentObject(...) and try to read it in a child view, your application will crash. Use it wisely for global data.

3. Factory Pattern and Dependency Containers

As your application grows in Xcode, manually injecting everything in the App.swift file becomes messy. An advanced pattern widely used by Swift experts is the “Dependency Container”.

We create a factory class that knows how to build all our app’s dependencies.

class AppDependencyContainer {
    // Shared Singleton for easy access
    static let shared = AppDependencyContainer()
    
    // Base services
    let networkService: UserDataService
    let authService: AuthenticationService
    
    private init() {
        self.networkService = NetworkDataService()
        self.authService = AuthenticationService()
    }
    
    // ViewModel Factories (Factory Method)
    func makeUserListViewModel() -> UserListViewModel {
        return UserListViewModel(service: networkService)
    }
}

Now, your application entry point is much cleaner, and you delegate the creation responsibility to the container:

@main
struct CleanArchitectureApp: App {
    let container = AppDependencyContainer.shared
    
    var body: some Scene {
        WindowGroup {
            // The container fabricates the ViewModel with all its dependencies resolved
            UserListView(service: container.networkService)
                .environmentObject(container.authService)
        }
    }
}

Testing: The Real Reason to Use DI

We cannot finish a tutorial on Dependency Injection in SwiftUI without demonstrating its greatest benefit: Unit Tests.

Because we decoupled our code, we can test business logic by simulating scenarios. Suppose we want to verify that our ViewModel correctly loads the user list. Open your test target in Xcode:

import XCTest
@testable import YourApp

class UserViewModelTests: XCTestCase {

    func testLoadData_Success() async {
        // 1. GIVEN (Given a controlled environment)
        let mockService = MockDataService() // Use the Mock, not the real network
        let viewModel = UserListViewModel(service: mockService)
        
        // 2. WHEN (When an action occurs)
        // Since the function is asynchronous, we wait for it to finish
        // Note: In a real test, you might need to adjust logic to wait for @Published
        // Here we simulate the direct call for the example
        
        let users = try? await mockService.getUsers()
        
        // 3. THEN (Then we verify the result)
        XCTAssertEqual(users?.count, 3)
        XCTAssertEqual(users?.first, "Test User 1")
    }
}

You have just created a deterministic test. It doesn’t matter if you have internet or not; this test will always validate your code logic. This is what differentiates a junior programmer from a Senior iOS Developer.

Conclusion

Mastering Dependency Injection in SwiftUI is not just a matter of code aesthetics; it is an architectural necessity for creating professional applications in Xcode. By decoupling your components, you make your code readable, maintainable, and above all, testable.

Whether you develop for iOS, macOS, or watchOS, the principles are the same. Start small: take one of your views that has a direct network call, create a protocol, inject it, and enjoy the peace of mind of having well-architected code.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Mastering visionOS Window Size in SwiftUI

Next Article

Design Patterns in SwiftUI

Related Posts