Dependency Injection

The 5th letter of SOLID refers to the usage of Dependency Injection. Or providing an instance with all of the things it needs to do its job - instead of having that instance having to reach out to other things for methods/functions.

DI is great. It’s what enables you to follow the rest of SOLID. It greatly eases tests (instead of using a real thing, you can use a test double), and it cleans up your code.

This page is meant to be an examination of DI strategies in Swift that I’m aware of.

I’m familiar with and have used Blindside (which is Objective-C, but the same principles apply), Swinject, Swift-dependencies, as well as various proprietary approaches.

Liskov Substitution, but for Packages

One App architecture opinion I strongly hold is that no third-party dependencies should be made integral to your app. For example, if you build your app on Core Data, and weave it throughout the app, you will have a very hard time if you ever decide to switch ORMs (even if it’s to, say, Swift Data, which uses Core Data under the hood), let alone databases. This is essentially Liskov Substitution, but applied to Packages.

This same principle applies to my opinions of DI frameworks. In my opinion, the best way to do DI is to have functions or initializers take in protocols for what they use, and elsewhere, use a DI framework to create instances use those initializers.

For example:

protocol AProtocol { ... }
protocol BProtocol { ... }

struct AStruct: AProtocol {
    let b: BProtocol
    init(b: BProtocol) { self.b = b }
}

// elsewhere

final class DependencyProvider {
    func a() -> AProtocol {
        AStruct(b: b())
    }
    
    func b() -> BProtocol { ... }
}

In my own projects, the DependencyProvider is actually a custom async-aware fork of Swinject.

Handling Makers

Sometimes, an instance needs to be able to make another instance. For example, needing to make the view model for the next view in a hierarchy. These should be injected as closures that take any dynamic dependencies. In other words, the Factory pattern.

For example:

import SwiftUI

protocol ViewModel {
    associatedtype View: SwiftUI.View
    var view: Self.View { get }
}

protocol AViewModelProtocol: ViewModel {
    associatedtype BViewModel: ViewModel
    var bViewModelFactory: (String) -> BViewModel { get }

    func bView(_ arg: String) -> BViewModel.View
}

final class AViewModel<BViewModel: ViewModel>: ViewModel, AViewModelProtocol {
    let bViewModelFactory: (String) -> BViewModel

    init(bViewModelFactory: @escaping (String) -> BViewModel) {
        self.bViewModelFactory = bViewModelFactory
    }

    var view: some View { AView(viewModel: self) }

    func bView(_ arg: String) -> BViewModel.View {
        bViewModelFactory(arg).view
    }
}

struct AView<ViewModel: AViewModelProtocol>: View {
    @State var viewModel: ViewModel
    var body: some View {
        VStack {
            NavigationLink("Hello") {
                viewModel.bView("hello")
            }
            NavigationLink("Goodbye") {
                viewModel.bView("goodbye")
            }
        }
    }
}

This is, admittedly, a lot of boilerplate just to deal with the generics.

You might think it might be worth it to wrap the (String) -> BViewModel closure syntax in a type. But, really, you’re only saving a small amount of characters, in exchange for the boilerplate of creating yet another type to essentially shuffle a closure around. If Swift had Objective-C’s frankly horrific closure syntax, then creating a separate type to wrap that would be worth it. But thankfully, Swift has relatively sane closure syntax.

Last updated: 2024-03-05 16:59:13 -0800