Lambdaspire
Skylark

Swift Dependency Injection

In lieu of official support...

Dependency injection in the Swift ecosystem is not yet a first-class citizen. Without a more sophisticated reflection API and/or support at the framework level (i.e. UIKit, SwiftUI), dependency injection will remain a constantly re-invented wheel. There are libraries out there, but the Apple development ecosystem incentivises a "Not Invented Here" mentality due to frequent deprecations and breaking changes. Reliance on third-party, open-source projects is usually met, eventually, with indeterminate delays when Xcode vNext hits the App Store.

In my quest for the most minimalistic, lowest surface area, interim solution, I've arrived at something that I'm reasonably content with... for now.

The Protocols

protocol DependencyRegistry {

    func registerSingleton<T>(_ t: T.Type, _ f: @escaping () -> T)
    func register<T>(_ t: T.Type, _ f: @escaping () -> T)
    
    func unregister<T>(_ t: T.Type)
}

protocol DependencyResolver {

    func resolve<T>() -> T
}

If nothing else, these protocols serve as a minimum-viable contract for the two roles we need to satisfy:

  • A registry of services.
  • A mechanism to resolve those services in a strongly-typed fashion via abstractions.

The Container

class Container : DependencyRegistry, DependencyResolver {
    
    static let shared: Container = .init()
    
    private var registrations: [String : () -> Any] = [:]
    
    func registerSingleton<T>(_ t: T.Type, _ f: @escaping () -> T) {
        var value: T!
        register(t, {
            if value == nil {
                value = f()
            }
            return value
        })
    }
    
    func register<T>(_ t: T.Type, _ f: @escaping () -> T) {
        registrations[key(for: t)] = f
    }
    
    func unregister<T>(_ t: T.Type) {
        registrations.removeValue(forKey: key(for: t))
    }
    
    func resolve<T>() -> T {
        guard let resolved = registrations[key(for: T.self)]?() as? T else {
            fatalError("Dependency resolution error for type \(T.self) from \(registrations.keys.joined(separator: ", "))")
        }
        return resolved
    }
    
    private func key(for type: Any.Type) -> String {
        .init(describing: type)
    }
}

Container just implements the two protocols. With some type erasure, resolutions go into a dictionary as closures returning Any, the results of which will be cast to the required type when invoked. There's a singleton shared instance which makes for quick and easy (and dirty) access anywhere in the code.

Here's an example of:

  1. Registering a dependency with a concrete implementation accessible via an abstraction.
  2. Resolving that dependnecy via that abstraction.
protocol Service {

    func doSomething()
}

class ServiceImplementation : Service {

    func doSomething() {
        print("I'll think of something and get back to you.")
    }
}

Container.shared.registerSingleton(Service.self, ServiceImplementation.init)

let service: MyService = Container.shared.resolve()

The PropertyWrapper

Our dependency resolution mechanism is little more than a glorified "Service Locator", and it shall remain so, but the current need to invoke some DependencyResolver's resolve() function feels like noisy boilerplate.

class MyDependency : Dependency {

    private var subDependency: SubDependency = Container.shared.resolve()
    private var yetAnotherDependency: AndSoOnAndSoForth = Container.shared.resolve()
    // etc
}

A PropertyWrapper will help us reduce said boilerplate...

@propertyWrapper struct Resolved<T> {

    let wrappedValue: T
    
    init(_ d: DependencyResolver = Container.shared) {
        wrappedValue = d.resolve()
    }
}

Thinking about / looking at it for too long will make you sad - perhaps nauseous - so let's instead look at the kind of legible, inoffensive syntax it enables:

class MyDependency : Dependency {

    @Resolved private var subDependency: SubDependency
    @Resolved private var yetAnotherDependency: AndSoOnAndSoForth
    // etc
}

A nominal improvement. You can even supply an alternative DependencyResolver as an argument to @Resolved(...) if, for whatever reason, you want to have multiple avenues to resolve dependencies.

Deficiencies and (as yet) Unanswered Questions

This approach certainly isn't perfect. It fails in irredeemably in some ways, and presents a learning curve elseways.

Where to Configure the Container?

Ideally, as early as possible in the application... For example, in the init of the App in a SwiftUI app.

@main
struct MyApp: App {
    
    init() {
        configureDependencies()
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

fileprivate func configureDependencies() {
    
    let c: Container = .shared
    
    c.registerSingleton(MyService.self, MyServiceImplementation.init)
    c.registerSingleton(MyOtherService.self, MyOtherServiceImplementation.init)
}

What about Tests? Previews?

Tests and Previews will need mock / stub implementations to be created for any resolved dependency abstraction where the real one isn't suitable. Once those are defined, it's simply a matter of configuring Container.shared inside the Test / Preview context.

Lack of Compile-Time Safety

You saw the code. If Container can't resolve something, it'll fatalError. Alas.

We could introduce a protocol constraint that enforces default values be supplied for "registerable" types... in a semi-purpose-defeating way.

Obscured Contracts / Hidden Dependencies

Indeed. The nature of dependency injection via properties like this means that dependencies are not visible in the init contract. This isn't so much a problem for code we control, but if a third-party library were to do something like this we'd run into aforementioned fatalErrors non-stop until we determined the full set of secret dependencies that needed to be registered.

I absolutely do not recommend writing libraries that manage dependency resolution like this. Keep it as a strictly "internal" implementation detail to minimise boilerplate.

Closing

Admittedly, this solution doesn't actually give us much more than what a Service Locator would. The PropertyWrapper is a thin veneer over that implementation detail. The goal isn't so much to achieve purity as it is to simplify our dependency resolution code so that:

  • we can have some abstraction,
  • that we don't have to think about too much,
  • with easily substituted implementations for tests and previews, and
  • will be easy (or, at least, low-effort) to remove if something 🍎 official comes along...

Every year during WWDC I cross my fingers and hope for Apple to introduce some deeply integrated IoC Container. SwiftUI's @Environment and @EnvironmentObject capabilities come close, but while the limitations on @StateObject exist (i.e. the initializer is a ⚠️ "do not use" ⚠️ situation) it doesn't scale well for complex dependency graphs without spreading construction logic and infrastructure far and wide throughout the codebase.

Despite its various shortcomings, this is the approach I like the most... Or, hate the least...

🤞 WWDC 2022, don't let me down. 🤞