• GitHub
  • Twitter
  • LinkedIn
  • RSS
  • Dependency Injection for Modern Swift Applications Part II

    Comparing four different approaches towards Dependency Injection

    Author:

    An AI generated hero image showing four syringes stuck into an apple, symbolifying the act of injecting dependencies on Apple platforms

    This article is a follow-up article to "Dependency Injection for Modern Swift Applications: Managing Dependencies in the Age of SwiftUI", however it should be fine to read this blog post as a stand-alone article.

    Whenever I reach a term explained in the previous article, I ensure it's linking to the specific section that details it, in case you're in doubt or want to read more.

    Introduction

    We've seen what are the most important aspects to Dependency Injection (DI) in general as well as specifically for SwiftUI. I've already introduced what are the most important aspects to evaluate the different kinds of solutions that are out there against each other:

    Now, we will examine each one of them in depth, holding them up individually against the qualities we are looking for in modern, well scaling SwiftUI solutions. The following four frameworks or techniques for DI have been selected to give a complete overview of all different types of solutions that exist:

    If you want the TL;DR of this article I recommend skipping directly to the Comparison table.

    Each of these approaches or frameworks are legit options in my opinion, but all come with their own pros and cons.

    I tried to select the best option in each category instead of just listing the most popular libraries. This is why for example a popular framework like Swinject is missing: Factory does the same, better.

    If you are really interested in how your frameworks of choice fares against all of these options, or think I missed a great framework that addresses some of the weaknesses I highlighted, reply in a comment or drop me an e-mail.

    I also dropped The Composable Architecture from the comparison because I could not find a satisfactory way to do any form of Generational Safety for the time being. It might return in the future!

    Manual Tree-Based Dependencies: Safe but Laborious and Hard to Maintain

    Passing down your dependencies manually is a frameworkless approach and Dependency Injection in its purest form. You can find an example implementation of Manual Dependency passing here. It guarantees that nothing can be built without its dependencies in place at run-time and it also allows you to easily create and inject dependencies later on in the app's lifecycle.

    This is why I regard this approach the baseline to which we should compare all other DI solutions.

    A diagram showing what dependency passing looks like. We observe that the middle Node knows about B even when it's neither a consumer nor a creator of this dependency

    But when we watch this diagram closely, we see that the middle node now is made responsible from getting dependency B from the Root node to the leaf that consumes it. This means that whenever one of the child nodes needs a dependency from a grandparent node, all of the nodes in between need to pass it forward.

    Take a look at the body of AuthenticatedView:

    let dependencies: AuthenticatedDependenciesContaining
    
    var body: some View {
        VStack {
            // AuthenticatedView needs to know what dependencies UserManagementView and StoriesView need
            // A small burden now, but it will increase exponentially with the app's complexity
            UserManagementView(userManager: dependencies.userManager)
            StoriesView(storyFetcher: dependencies.storyFetcher)
            // …
        }
    }

    We see that it now knows about the UserManager dependency for UserManagementView. The same goes for StoriesView and StoryFetcher.

    I also find it hard to integrate it properly in SwiftUI applications, which should have a loosely coupled structure by themself because they rebuild every View at sometimes unexpected moments, but require the right dependencies to propagate at the right time.

    Take a closer look at what happens in the body of the AppView:

    var body: some View {
        var body: some View {
            switch viewModel.state {
            case let .authenticated(token):
                // Watch out: the dependencies get recreated every time the View gets re-rendered
                // Is this what you really want? Only when your dependencies are truly stateless!
                AuthenticatedView(dependencies: dependencies(for: token))
            case .loggedOut:
                LogInView(dependencies: logInDependencies)
            }
        }
    }

    If there would be some kind of state mutation happening inside one of the dependencies built in AuthenticatedDependencies - which is an anti-pattern by itself, but we've all been in this place! - it would be overwritten the moment AppView would be rebuilt, leading to all kinds of underterministic behavior at run-time.

    Having a structure based upon nested Factories would be safer, but comes at a price of complexity. You can find a small example of the Factory pattern here:

    class AuthenticatedFactory: AuthenticatedDependenciesContaining {
        let logInSwitcher: LogInSwitching
        let userManager: UserManaging
        let storyFetcher: StoryFetching
    
        /// Everything that is needed to build this Factory needs to be passed forward, including dependencies already created in a parent node
        /// - Parameters:
        ///   - token: The key to unlock the `UserManager` and `StoryFetcher` implementations
        ///   - logInSwitcher: A dependency we need to pass forward from the root
        init(
            token: String,
            logInSwitcher: LogInSwitching // In Needle, all dependencies inherited from all nodes toward and including the root are handled by the `parent` Component
        ) {
            self.logInSwitcher = logInSwitcher
            userManager = UserManager(token: token)
            storyFetcher = StoryFetcher(token: token)
        }
    }
    
    // A separate extension for building the Views
    // Ideal for keeping your Factories UI dependency free, or for supporting multiple platforms
    extension AuthenticatedFactory {
        var userManagementView: UserManagementView {
            UserManagementView(userManager: userManager)
        }
    
        var storiesView: StoriesView {
            StoriesView(storyFetcher: storyFetcher)
        }
    }

    This is a bit more work than using a dumb pass-forward container, but it removes all of the dependency logic from all other layers entirely, like in this example:

    let factory: AuthenticatedFactory
    
    var body: some View {
        VStack {
            // This View now knows nothing about how these Views are built
            factory.userManagementView
            factory.storiesView
            Button {
                isLoggingOut = true
            } label: {
                Text("Log Out")
            }.task(id: isLoggingOut, priority: .high) {
                guard isLoggingOut else {
                    return
                }
    
                defer {
                    isLoggingOut = false
                }
    
                await factory.logInSwitcher.loggedOut()
            }
        }
    }

    While it's not shorter, all of the injection related tasks have completely disappeared. And if we would have put the factory behind a protocol, we could even have different ways of building this View without touching the View.

    Usability

    It's fairly easy to get into as it's really, really explicit in how it works and the compiler will simply complain when you don't have a certain dependency.

    However you will find yourself doing quite a bit of work to add dependencies or when you need to change anything in the way the tree is structured. Also you will find that some of the parent branches of the tree have to manage dependencies that are relevant for neither themselves nor for its direct children.

    Because you have total control over the solution it's simply a matter of declaring every dependency as a protocol and after that everything is easily mockable for testing, but it lacks helpers for testing or previews.

    These issues can be better hidden but not completely mitigated when using Factories.

    @Environment and @EnvironmentObject: Possible Crash Hazards

    Environment and EnvironmentObject are Apple's solution for injecting values and dependencies in SwiftUI. The interesting thing about them is that you can register or overwrite them through functions on Views and access them through special accessors within this tree of Views:

    @MainActor
    struct AppView: View {
        @StateObject var viewModel: AppViewModel
    
        var body: some View {
            switch viewModel.state {
            case let .authenticated(token):
                // AppView is now responsible for passing the right dependencies into `environment` or `environmentObject`
                // Failing to set the `UserManager` will result in the `PlaceholderUserManager` being used
                // Failing to set the `StoryFetcher` will result in a crash when it's accessed
                // It does work really well to keep certain dependencies only in one part of the tree
                AuthenticatedView()
                    .environment(\.userManager, UserManager(token: token))
                    .environment(StoryFetcher(token: token))
            case .loggedOut:
                LogInView()
            }
        }
    }

    I created a small demo app that showcases various aspects of SwiftUI Environment where you can find this AppView.

    Using Environment

    The @Environment property wrapper came first and was mainly used to story all kinds of values around the current app's global state, like your current Locale or color scheme, but it also allowed you to register custom values for yourself.

    Here's an example where we register the Authentication actor:

    private struct AuthenticationEnvironmentKey: EnvironmentKey {
        static let defaultValue: Authenticating = Authentication()
    }
    
    extension EnvironmentValues {
        var authentication: Authenticating {
            get { self[AuthenticationEnvironmentKey.self] }
            set { self[AuthenticationEnvironmentKey.self] = newValue }
        }
    }

    We always need to provide a default value ourself (or leave it nil), but we can overwrite this value at any given point in the SwiftUI tree using the .environment modifier, like here for AuthenticatedView.

    AuthenticatedView()
        .environment(\.userManager, UserManager(token: token))

    It works fine with protocols and is a hybrid between the tree based approach and the statically declared approach. It's quite safe to use, but like many other solutions, forces you to override Placholder or nil values if you don't have the right value or dependency immediately when the app starts.

    When using the @Observable Macro

    It's also possible to store instances of classes that use the @Observable macro without using a custom environment key and value:

    @Environment(Library.self) private var library

    This takes away a lot of boiler plate code and doesn't require a value right when the app boots, but comes with a big downside: our application crashes run-time if we did not set a Library in our environment beforehand in the SwiftUI. This can be mitigated by making the type optional. In that case or value for library will be nil when it's not set:

    @Environment(Library.self) private var library: Library?

    While it prevents crashes we might run into a situation where we don't have a Library while trying to perform Library-related tasks. This might lead to unexpected side-effects.

    Another downside is that you cannot hide the implementation behind a protocol anymore, making mocking for previews or tests harder.

    Using EnvironmentObject

    EnvironmentObject looks almost the same as using Environment for Observable on the surface, just adding the word Object to any Environment function:

    AppView()
        .environmentObject(StoryFetcher(token: token))

    ...and modifier:

    @EnvironmentObject var storyFetcher: StoryFetcher

    But it's actually quite different:

    It removes the manual pass-through boilerplate and it's very easy to use, but it comes with the same caveats as non-keyed @Environment dependencies: you're not forced to always set a value. This means that if you try to access a given dependency through the environment and when it hasn't been set yet, the application crashes. So when you re-structure and expand your app you might introduce a crash somewhere else without knowing it, just by accidentally missing a registration:

    This diagram shows the EnvironmentObject approach. It looks really simple as it mimics the creation and consumption locations, but we see that the app crashes because dependency A has not been set on the Root node

    In this example we forgot to set dependency A on the Root node. This compiles without a warning. But when we try to use the child node that asks for this dependency, our application crashes:

    SwiftUI/EnvironmentObject.swift:90: Fatal error: No ObservableObject of type LogInSwitcher found. A View.environmentObject(_:) for LogInSwitcher may be missing as an ancestor of this view.

    It's hard to prevent these crashes without extensively testing the full application, manually or in an automated way. EnvironmentObject or type-based Environment can become a scaling issue for larger applications because of this.

    Both Are Strongly Tied to Your View Hierarchy

    Also, since it leverages the SwiftUI State tree to store these dependencies, they can only be accessed through Views. Applications that look more like a network of interdependent services communicating with eachother rather than being UI driven are not so easy to realize.

    Also ViewModels cannot access these property wrappers you still need to inject the dependencies manually in them first, leading to chicken-and-egg problems where you cannot build a ViewModel for your View before you can access a dependency found in @Environment.

    Usability

    Given the fact that this is the Apple-sanctioned DI solution, it's safe to assume that every developer you hire already worked with it or at least read about it. If not, it's very well documented and fairly easy to get into. It's also very easy to set up dependencies on tests and previews.

    Factory

    Michael Long's DI project called Factory is one of the many statically declared DI solutions that exist. I picked this one because I like it more than the other libraries in the same category, though more exist.

    It's a classic statically declared approach, where you register a dependency in a Container that can be registered and accessed globally or passed along in pretty much the same way as the manual tree approach we just discussed. Most people will use it as a "dumb" global Statically Declared Container and that's perhaps just its strength.

    It's a very small library of just a few hundreds of lines of code so it doesn't impact your build times. It has quite a few extra features that I liked like the graph scope, that (re)creates a dependency only when needed (again) and will automatically deinit a it if there is no longer any reference to it.

    It also allows you to set up "Contexts" that will have default implementations for Previews, Testing and so on.

    Lastly, it doesn't create dependencies until they are needed, meaning you prevent unnecessary cycles spent at the app's start for dependencies only consumed later.

    Safety

    When it builds, you know it will run safely. The only run-time issue is that it's possible to create a circular dependency, but it will give you a very explanatory custom crash about it, otherwise it's 100% compile-safe.

    What is doesn't do well are generational dependencies. The only way to have a dependency created at a later point is by declaring a default value first, to be overwritten manually later on, or by making it optional, like we see in the diagram happening to dependency C. We can't create it's true value until we go from the second child node to the second grandchild node, but the Container already needs some kind of declaration when the app starts:

    An example diagram of a statically declared DI solution. Since C is created later than the Container, we have an issue

    In this case I resorted to Placeholder values, that should be overwritten once we have a valid token:

    extension Container {
        // …
        var userManager: Factory<any UserManaging> {
            self { PlaceholderUserManager() }.graph
        }
    
        var storyFetcher: Factory<any StoryFetching> {
            self { PlaceholderStoryFetcher() }.graph
        }
    }

    Now take a look at the code that is required to have the right dependency at the right time:

    @MainActor
    class AppViewModel: ObservableObject {
        // …
        private func observe(logInSwitcher: any LogInSwitching) async {
            for await token in logInSwitcher.tokenChannel {
                // First assign the dependencies
                AuthenticatedDependenciesManager.handle(token: token)
                // …
            }
        }
    }
    
    class AuthenticatedDependenciesManager {
        static func handle(token: String) {
            switch token.isEmpty {
            case true: break
            case false: register(with: token)
            }
        }
    
        static func register(with token: String) {
            Container.shared.userManager.register { UserManager(token: token) }
            Container.shared.storyFetcher.register { StoryFetcher(token: token) }
        }
    }

    We are responsible to set and unset the dependency ourselves. Sometimes the .graph modifier can help us to automatically remove an overridden value for a dependency, like in this example. As soon as there is no reference to userManager or storyFetcher anymore, the (weak) value held by Factory will be dropped and replaced a default Placeholder instance, unless directly overwritten like in our case.

    Because we need to take care of this semi-manually it still allows your dependencies to be broken when refactoring or re-using features, without any warning at compile-time. Another way to get out of this conundrum is to revert to passing this kind of dependencies forward by hand, which is a valid pattern within this framework but comes down to exactly the same as Manual Tree-Based Dependencies: Safe but Laborious and Hard to Maintain todo link.

    Usability

    It's the easiest solution out here in terms of learning curve and ergonomy, by far. It still helps to read the documentation to find out about the more advanced features.

    Needle: Security Comes at a Price

    Uber's DI solution is by far the most technically complex solution proposed in this article. It admirably tries to merge the compile-time guarantees you get from using a manual or Statically Declared approach todo link with the Timeline safety todo link you get from a manual approach, while entirely cutting down the boilerplate code and responsibility mixing that comes with the manual approach.

    This graphic shows how Needle achieves a simple but loosely-coupled tree dependency style by generating the boiler plate code in a Build Phase

    It achieves this almost impossible sounding feat by using a code generation step that generates all of the manual boiler plate code for you. From the programmer's perspective it works like the tree-based approach, but the generated code makes it work like a manual bucket-brigade solution. We just never have to see or maintain this code.

    The way you structure your components in Needle reminded me of the nested Factories approach for manually Passed Dependencies todo link, where the Factories are renamed Components. A Component can create new or consume existing dependencies declared in it's Dependency protocol.

    Here's an example of our RootComponent that is used a the root for all other Components:

    class RootComponent: BootstrapComponent {
        var logInComponent: LogInComponent { LogInComponent(parent: self) }
    
        func authenticatedComponent(token: String) -> AuthenticatedComponent {
            AuthenticatedComponent(
                parent: self,
                token: token
            )
        }
    }
    
    extension RootComponent: LogInDependency {
        var logInSwitcher: any LogInSwitching {
            shared { LogInSwitcher() }
        }
    }

    We see it can build two new Components:

    Let's see what the AuthenticatedComponent looks like:

    protocol AuthenticatedDependency: Dependency {
        var logInSwitcher: any LogInSwitching { get }
    }

    The AuthenticatedDependency declares we want a logInSwitcher from one of our parent nodes. Either one of the parent nodes has this dependency (in this case RootComponent), or we get a build error from the code generation build step that looks like:

    warning: ❗️ Could not find a provider for (logInSwitcher: LogInSwitching)
        which was required by AuthenticatedDependency, along the DI branch of 
        ^->RootComponent->AuthenticatedComponent.
    warning: ❗️ Missing one or more dependencies at scope.

    The reset of the AuthenticatedComponent looks as follows:

    class AuthenticatedComponent: Component<AuthenticatedDependency> {
        var userManager: any UserManaging {
            return shared { UserManager(token: token) }
        }
    
        var storyFetcher: any StoryFetching {
            return shared { StoryFetcher(token: token) }
        }
    
        private let token: String
    
        init(
            parent: Scope,
            token: String
        ) {
            self.token = token
            super.init(parent: parent)
        }
    }
    
    protocol AuthenticatedViewBuilding {
        var authenticatedView: AuthenticatedView { get }
        var storiesView: StoriesView { get }
        var userManagementView: UserManagementView { get }
    }
    
    extension AuthenticatedComponent: AuthenticatedViewBuilding {
        var authenticatedView: AuthenticatedView {
            AuthenticatedView(
                logInSwitcher: dependency.logInSwitcher, // The `logInSwitcher` 
                component: self
            )
        }
    
        var storiesView: StoriesView {
            StoriesView(storyFetcher: storyFetcher)
        }
    
        var userManagementView: UserManagementView {
            UserManagementView(userManager: userManager)
        }
    }

    The logInSwitcher we required earlier through our Dependency protocol is now used to create the AuthenticatedView. But it will also be available in any child component, together with the userManager and storyFetcher.

    This way Needle builds up it's tree of dependencies: create once, use forever.

    Safety

    It requires you to define all of the dependencies that you need for a certain class and to provide them if they have not been created already before in the dependency tree. It inherits all dependencies you have set towards all branches and leafs, throwing a compiler error any time you fail to set a dependency in one of the parent nodes when a child needs to access it. This way, it's impossible to have run-time crashes but you do retain the flexibility to register dependencies later on in the app's lifecycle, like dependency C.

    Usability

    It's a complex solution generally, requiring quite a bit of knowledge to be used. Also, it has quite an impact on your architecture as you're forced to use nested Components. You don't want to fully commit to Needle only to regret the decision and wanting to roll it back, unless you convert your Components to Factories.

    Having said that it's pretty predictable in how it works after setting up your project. If you figured out how to create and add dependencies once you're pretty much set. But there were quite a few hurdles to get started:

    The Comparison

    This table lists all features we've discussed before individually, per framework:

    ✅ Fully supported ⚖️ Has some issues 🚫 Not supported at all

    Approach Registration Consumption Compile-Time Safety Generational Safety Scalability Usability Testability
    Manual Tree Tree Initializer 🚫 ⚖️
    EnvironmentObject Tree Service Locator ⚖️ ⚖️ ⚖️
    Factory Static Service Locator 🚫
    Needle Tree Initializer ⚖️

    The Conclusion

    As you see, the Manual Tree approach actually scores really well. However, the Scalability issues are so severe I would not advise to use it for anything but smaller modules that really need all of that security and cannot rely too much on outside frameworks. Utilizing the Factory pattern would mitigate some of these issues, and in all fairness makes it come closer to Needle in terms of scalability.

    If you're developing a Swift Package yourself or anything else that has a guaranteed small scale and preferably has no sub-dependencies this would be the go-to approach.

    Given the fact that Compile-Time Safety was a must-have feature for me, I only ended up with two other strong options:

    Factory is very easy to use and not so intrusive, especially compared to Needle, that requires you completely restructure your project towards its needs. But Needle is the only framework that combines generational safety with compile-time safety, without forcing the developer to write a lot of pass-forward boiler plate code.

    Once you drop the hard requirement of compile-time safety Environment and EnvironmentObject become viable options. But you do need to take care that for non-keypath @Observed Environment and EnvironmentObject dependencies there is no safety net.

    Practical Advice

    When to introduce Needle?

    Given the all-or-nothing approach Needle requires and its learning curve and build complexity, it's not a great fit for small teams and projects. But the longer you wait, the more you need to refactor towards using it, so there's a benefit of using.

    This presents a real dilemma for teams wanting to switch to it, that can't easily be solved like any problem at scale.

    I can only advice to keep it as an option whenever you start to see issues related to the limitations of other solutions, like frequent refactors needed around dependencies, run-time crashes or slow app boots.

    Also, if your project is guaranteed to get big, for example when starting a project like Threads for Meta, you might want to consider using it from the get-go.

    An image of the author
    is an Apple platforms developer that likes to write and discuss about technical subjects.

    Do you:

    Drop me a line!

    Contact Me