Skip to content
intermediate Phase 3 · App Lifecycle & Architecture

Dependency Injection

Use protocol-based DI, environment injection, and factory patterns for loose coupling.

45m
2 problems
Topic Progress 0%

Protocol-Based DI

What Is Dependency Injection?

Dependency Injection (DI) is the practice of providing objects with their dependencies from outside, rather than having them create dependencies internally. This makes code loosely coupled and testable.

// Without DI - tightly coupled
class UserService {
    func fetchUser() async throws -> User {
        let apiClient = ApiClient()  // hardcoded
        return try await apiClient.request(.user)
    }
}

// With DI - loosely coupled
class UserService {
    private let apiClient: ApiClientProtocol
    
    init(apiClient: ApiClientProtocol) {
        self.apiClient = apiClient
    }
    
    func fetchUser() async throws -> User {
        return try await apiClient.request(.user)
    }
}

Protocol-Based Injection

Define dependencies as protocols, inject concrete implementations:

protocol AnalyticsServiceProtocol {
    func track(event: String, properties: [String: Any])
    func identify(userId: String, traits: [String: Any])
}

class MixpanelAnalytics: AnalyticsServiceProtocol {
    func track(event: String, properties: [String: Any]) {
        Mixpanel.sharedInstance().track(event: event, properties: properties)
    }
    func identify(userId: String, traits: [String: Any]) {
        Mixpanel.sharedInstance().identify(userId)
    }
}

class MockAnalytics: AnalyticsServiceProtocol {
    var trackedEvents: [(String, [String: Any])] = []
    func track(event: String, properties: [String: Any]) {
        trackedEvents.append((event, properties))
    }
    func identify(userId: String, traits: [String: Any]) {}
}

Constructor Injection

The most explicit form - dependencies are required in the initializer:

@Observable
class CheckoutViewModel {
    private let paymentService: PaymentServiceProtocol
    private let cartService: CartServiceProtocol
    private let analytics: AnalyticsServiceProtocol
    
    init(
        paymentService: PaymentServiceProtocol,
        cartService: CartServiceProtocol,
        analytics: AnalyticsServiceProtocol
    ) {
        self.paymentService = paymentService
        self.cartService = cartService
        self.analytics = analytics
    }
}

Constructor injection makes dependencies visible and testable. Every dependency is explicit.

Property Injection

For optional or contextual dependencies:

class MyViewController: UIViewController {
    var analyticsService: AnalyticsServiceProtocol?
    var themeProvider: ThemeProviderProtocol?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        analyticsService?.track(event: "screen_view", properties: [:])
    }
}

Property injection is less safe (dependencies can be nil) but useful for optional services.

Environment Injection

Using SwiftUI Environment for DI

SwiftUI environment provides a clean way to inject dependencies without passing them through every view:

struct AnalyticsServiceKey: EnvironmentKey {
    static let defaultValue: AnalyticsServiceProtocol = NoOpAnalytics()
}

extension EnvironmentValues {
    var analytics: AnalyticsServiceProtocol {
        get { self[AnalyticsServiceKey.self] }
        set { self[AnalyticsServiceKey.self] = newValue }
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.analytics, MixpanelAnalytics())
        }
    }
}

struct BuyButton: View {
    @Environment(\.analytics) var analytics
    
    var body: some View {
        Button("Buy") {
            analytics.track(event: "purchase_tapped", properties: [:])
        }
    }
}

Environment with @Observable

For complex dependencies with state, use @Observable and .environment():

@Observable
class UserStore {
    var currentUser: User?
    var isAuthenticated = false
    
    func login(email: String, password: String) async throws {
        // login logic
    }
}

@main
struct MyApp: App {
    @State var userStore = UserStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(userStore)
        }
    }
}

struct ProfileView: View {
    @Environment(UserStore.self) var userStore
    
    var body: some View {
        Text(userStore.currentUser?.name ?? "Guest")
    }
}

Environment vs Constructor Injection

Approach Pros Cons
Constructor Explicit, visible Requires passing through every level
Environment Implicit, clean syntax Hidden dependencies, harder to trace

Use constructor injection for ViewModels with explicit dependencies. Use environment for cross-cutting concerns like analytics, theming, and logging.

Factory Patterns

Factory Protocol

A factory encapsulates object creation, hiding the complexity of dependency graphs:

protocol ViewModelFactoryProtocol {
    func makeUserListViewModel() -> UserListViewModel
    func makeUserProfileViewModel(userId: UUID) -> UserProfileViewModel
    func makeSettingsViewModel() -> SettingsViewModel
}

class ViewModelFactory: ViewModelFactoryProtocol {
    private let dependencies: AppDependencies
    
    init(dependencies: AppDependencies) {
        self.dependencies = dependencies
    }
    
    func makeUserListViewModel() -> UserListViewModel {
        UserListViewModel(
            fetchUsers: FetchUsersUseCase(repository: dependencies.userRepository),
            analytics: dependencies.analytics
        )
    }
    
    func makeUserProfileViewModel(userId: UUID) -> UserProfileViewModel {
        UserProfileViewModel(
            userId: userId,
            fetchUser: FetchUserUseCase(repository: dependencies.userRepository),
            updateUser: UpdateUserUseCase(repository: dependencies.userRepository)
        )
    }
    
    func makeSettingsViewModel() -> SettingsViewModel {
        SettingsViewModel(preferences: dependencies.preferences)
    }
}

Environment-Based Factory

Inject the factory through the environment:

struct ViewModelFactoryKey: EnvironmentKey {
    static let defaultValue: ViewModelFactoryProtocol = PreviewFactory()
}

extension EnvironmentValues {
    var viewModelFactory: ViewModelFactoryProtocol {
        get { self[ViewModelFactoryKey.self] }
        set { self[ViewModelFactoryKey.self] = newValue }
    }
}

// Views request VMs from the factory
struct UserListScreen: View {
    @Environment(\.viewModelFactory) var factory
    
    var body: some View {
        UserListView(viewModel: factory.makeUserListViewModel())
    }
}

Service Locator Pattern

A centralized registry that provides dependencies by type:

class ServiceLocator {
    static let shared = ServiceLocator()
    private var services: [String: Any] = [:]
    
    func register<T>(_ service: T, for type: T.Type) {
        let key = String(describing: type)
        services[key] = service
    }
    
    func resolve<T>(_ type: T.Type) -> T? {
        let key = String(describing: type)
        return services[key] as? T
    }
}

// Register
ServiceLocator.shared.register(ApiUserRepository(), for: UserRepository.self)

// Resolve
let repo = ServiceLocator.shared.resolve(UserRepository.self)

Choosing an Approach

Pattern Best For Trade-offs
Constructor DI ViewModels, Use Cases Verbose but explicit
Environment DI Cross-cutting concerns (analytics, theme) Clean but hidden
Factory Complex object creation Extra abstraction layer
Service Locator Small apps, quick prototyping Hidden dependencies, harder to test

DI Best Practices

  1. Prefer constructor injection for business logic (ViewModels, Use Cases)
  2. Use environment for truly global services (analytics, logging, theming)
  3. Define protocols for all external dependencies (API, database, storage)
  4. Provide default implementations for preview and testing contexts
  5. Keep the dependency graph shallow - avoid transitive dependencies
  6. Use factories when object creation involves complex setup
  7. Never use service locator in production apps - it hides dependencies

Quiz

1. What is dependency injection?

Question 1 options

2. What is the most explicit form of dependency injection?

Question 2 options

3. How do you inject dependencies via SwiftUI environment?

Question 3 options

4. What is a ViewModel factory used for?

Question 4 options

5. Why is the service locator pattern discouraged in production?

Question 5 options

Flashcards

Question

What is dependency injection?

Answer

Providing objects with their dependencies from outside instead of creating them internally.

Question

What is constructor injection?

Answer

Requiring all dependencies in the initializer, making them explicit and mandatory.

Question

How do you inject via SwiftUI environment?

Answer

Define EnvironmentKey with default value, extend EnvironmentValues, inject with .environment().

Question

What does a factory pattern do?

Answer

Encapsulates complex object creation and dependency wiring behind a protocol.

Question

Why avoid service locator?

Answer

It hides dependencies behind a global registry, making code harder to test and reason about.

Revision Notes

Key Takeaways

  • 1. DI provides dependencies externally for loose coupling and testability
  • 2. Constructor injection is the most explicit and recommended approach
  • 3. SwiftUI environment enables clean dependency injection for cross-cutting concerns
  • 4. Factory patterns encapsulate complex object creation and wiring
  • 5. Avoid service locator pattern in production code

Interview Tips

  • Explain the difference between constructor, property, and environment injection
  • Know when to use environment vs constructor injection
  • Be ready to design a DI strategy for a given app architecture
  • Discuss why service locator is problematic for testing and maintainability

Cheat Sheet

DI: provide dependencies externally, not internally.
Constructor injection: explicit, preferred for ViewModels.
Environment injection: clean for cross-cutting concerns.
Factory: encapsulates complex object creation.
Service locator: discouraged in production.
Always define protocols for testability.