paint-brush
How to Stop Making Singletons in Swift: A Dependency Injection Guideby@ivkuznetsov

How to Stop Making Singletons in Swift: A Dependency Injection Guide

by Ilia KuznetsovNovember 10th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This guide explains how to replace singletons with dependency injection (DI) in a SwiftUI app, using a DependencyContainer framework. It demonstrates how to inject services through property wrappers, manage dependencies with keys, and swap implementations for testing with ease. The solution enhances flexibility, modularity, and testability in your app.
featured image - How to Stop Making Singletons in Swift: A Dependency Injection Guide
Ilia Kuznetsov HackerNoon profile picture


This article outlines how to transition from singletons to dependency injection in a SwiftUI app with minimal effort.


If you're here, you likely already know that you should avoid singletons with various of reasons like:


  • Hidden Dependencies: Classes rely on singletons without explicit indicators, making it harder to track app dependencies and leading to unintended coupling.
  • Circular Dependencies: Singletons depending on each other can create cycles, leading to initialization deadlocks as instances wait for each other.
  • Testing Challenges: Using singletons complicates testing and SwiftUI previews since singletons can't easily be swapped for mocks.


Lack of Flexibility: Singletons make it difficult to provide different implementations for different environments (e.g., live vs. mock versions for testing).


And many others.

Dependency Injection

To overcome most issues we can use Dependency injection (DI). Using it, swapping in a new implementation becomes much easier, as components don’t rely on a single shared instance but on protocols that can be implemented in different ways. This promotes flexibility, easier testing, and adaptability as app requirements evolve.

Constructor Injection

Constructor injection is a straightforward technique for injecting dependencies by passing them through initializers. While effective, it often increases the size and complexity of initializers.


A common challenge with constructor injection is passing dependencies through a deep UI hierarchy. If a dependency is only needed on a specific screen but must be passed down through multiple intermediary screens, this can quickly become cumbersome.


One way to handle this is by using the Coordinator pattern, which centralizes navigation and manages dependencies more efficiently. Check out this article to learn how to make it.

Why Not Use @EnvironmentObject?

While @EnvironmentObject simplifies dependency sharing in SwiftUI, it has limitations:

  • It only works with ObservableObject
  • It doesn’t allow using protocols for implementation switching.
  • It only works in SwiftUI views hierarchy
  • It only checks for service existence when accessed, increasing the risk of unintentional omissions in views.

Dependency injection with property wrappers

This is similar to the @EnvironmentObject solution where the dependency management happens inside of property wrappers, but without drawbacks.

DependencyContainer

This small library contains only a couple files, can be copied directly into your project or integrated via SPM.

Please, make sure you put a star on it if you like it.


Here is the link on GitHub.


Let’s create an example of the service we need to pass as a dependency.

As we are assuming that we are going to make a mock for testing, we need to implement our service as a protocol.

Also we make it as ObservableObject, to review additional features of the framework.

import Combine

protocol DataRepository: Actor, ObservableObject {
    
    func fetchData() async throws
}

actor DataRepositoryImp: DataRepository {
    
    func fetchData() async throws {
        // do real stuff
    }
}

actor DataRepositoryMock: DataRepository {
    
    func fetchData() async throws {
        // do mocked stuff
    }
}


Defining a DI container

Once integrated, you’ll set up a DI container to manage your services. Each service requires a unique key. You have no limitations in creating keys with the same service type.

import DependencyContainer

extension DI {
    static let data = Key<any DataRepository>()
}


Next let’s create a function for registering service in DI Container.

extension DI.Container {
    
    static func setup() {
        register(DI.data, DataRepositoryImp())
    }
}


Then, call this function at app startup.

@main
struct ExampleApp: App {
    
    @MainActor final class AppState: ObservableObject {
        
        init() {
            DI.Container.setup()
        }
    }
    @StateObject var state = AppState()
    
    var body: some Scene {
        WindowGroup {
            MainView()
        }
    }
}


Injecting Dependencies

The actual injection is done with several property wrappers. The most common one is @DI.Static. You just need to pass a key we defined earlier.

struct MainView: View {
    
    @DI.Static(DI.data) var data
    
    var body: some View {
        Color.clear.task {
            try? await data.fetchData()
        }
    }
}


The points we need to make attention to:


  • No Need to Specify Type. As you may have noticed we don’t need to write a type here, because Swift gets the type from the key.

    It’s very convenient in case you start with a concrete type for your service and later decide to use a protocol to enable mocking. You only need to update the type in the key definition. This change propagates automatically without requiring updates to any code referencing that service.


  • Dependency Existence Checked on Initialization. The property wrapper checks that the service exists when initialized. This helps prevent accidental circular dependencies, where services might try to reference each other. The framework enforces a clear initialization order, encouraging thoughtful dependency management and avoiding deadlocks.


Additional property wrappers:


@DI.Observed Use this in SwiftUI views when your service is ObservableObject. Views will be updated when the service is changed.

struct MainView: View {
    
    //updates the view when data is posting objectWillChange
    @DI.Observed(DI.data) var data
    
    var body: some View {
        Color.clear.task {
            try? await data.fetchData()
        }
    }
}


@DI.RePublished Should be used in ObservableObject. If your service is ObservableObject, the update will be republished further by the owner of the property wrapper.

struct MainView: View {
    
    final class State: ObservableObject {
        
        // Republishes the data.objectWillChange to the State.objectWillChange
        // The view will be updated
        @DI.RePublished(DI.data) var data
    }
    
    @StateObject var state = State()
    
    var body: some View {
        Color.clear.task {
            try? await data.fetchData()
        }
    }
}


In addition, if you have nested services you can use a key path in property wrappers. In this case the observing logic uses the service referenced by a keypath.

@DI.Static(DI.data, \.innerService) var innerService

@DI.Observed(DI.data, \.innerService) var innerService

@DI.RePublished(DI.data, \.innerService) var innerService


Mocking

To swap out implementations for testing or previews, you have two options:


  • Initialize with mocked dependency

    MainView(data: .init(value: DataRepositoryMock()))
    


  • Replace service with mock in DI Container

    DI.Container.register(DI.data, DataRepositoryMock())
        
    MainView()
    

Conclusion

This guide provides a straightforward approach to adopting DI using a DependencyContainer framework and moving away from singletons, enhancing flexibility, modularity, and testability in your iOS apps.