Factory for DI

Factory for DI

In my previous post about Dependency Injection in iOS, I mentioned that factories can be used for DI and it's straightforward to implement. Today, we will figure out how to do it, what options are available, and why it may be more advantageous than using libraries like Swinject.

NOTE: In this post, I assume you are already familiar with the concept of DI (but you can learn more about it from my previous post referenced above).

Factory pattern

The Factory pattern is a design pattern commonly used in software development for object creation. It encapsulates the logic for creating objects within a separate class or method, based on specific parameters or conditions.

enum ViewFactory {
    static func createView() -> UIView {
        UIView()
    }
}
let view = ViewFactory.createView()

It allows to extract the object creation logic from the calling point and abides by Single Responsibility Principle. This helps to keep the object creation logic organized and centralized, making it easier to maintain, update, and test.

The Factory pattern promotes loose coupling between the client code and the objects being created. Clients only depend on the factory, not on concrete classes, which allows for easier maintenance and reduces the dependencies between different parts of the codebase.

Factory pattern for DI

Factories can be very useful for implementing DI in your application. For example, you can create a login screen with the factory and inject all required dependencies there:

protocol AuthService {}
enum AuthServiceFactory {
    static func create() -> AuthService {
        AuthServiceImpl()
    }
}
enum LoginScreenFactory {
    static func createLoginScreen() -> UIViewController {
        let authService = AuthServiceFactory.create()
        let loginVM = LoginViewModel(authService: authService)
        return LoginViewController(viewModel: viewModel)
    }
}
let loginVC = LoginScreenFactory.create()
// presenting your screen

As you can see, the factory creates all dependencies for this MVVM module and inject them so we have the view controller ready to present as a result.

You can use factories to create screens, services or anything you need in your application.

In smaller iOS applications, using factories can be a viable solution for DI. Factories can provide a simple and lightweight way to handle DI without the need for external third-party libraries or frameworks.

When it’s not enough?

While factories can be a suitable approach for managing dependencies in smaller iOS applications, they may not be sufficient for larger, more complex projects.

In large iOS projects with numerous dependencies and complex dependency graphs, managing dependencies through factories can become more challenging. You need to control the lifecycle of your dependencies to manage them properly and it’s not what we have out of the box with the factory pattern. On top of that, it would be useful to detect dependency cycles and mock dependencies easily for unit testing. Although it is possible to implement these features, it can be tricky, especially for those not familiar with how DI containers work under the hood.

Let’s summarize what we expect from a proper DI solution and what factories actually capable of:

  • Compile-time safety

    When you do DI we expect it should be compile-time safe. This means that the code will not compile if there are any errors related to dependency injection, such as missing or mismatched dependencies, or if dependencies are not registered with the container. We want to avoid runtime crashes, and this can be accomplished by avoiding runtime resolvers like Swinject. As the application grows larger, it becomes increasingly difficult to ensure that all modules use only registered dependencies.

  • Laziness

    We want our dependencies to be lazy. It means that objects are created and initialized when they are actually needed, rather than creating and initializing all objects at once on application start. This can help improve the performance of an application, as it reduces the amount of memory and processing power required at startup. This is especially important in larger applications, where there may be a large number of objects and dependencies to manage.

  • Control the scope/lifecycle (+ easily reset the scope when needed)

    DI container should be responsible for creating and initializing objects, managing their state during runtime, and destroying them when they are no longer needed. It can help reduce memory usage by avoiding to let unused dependencies exist in memory and releasing other system resources when they are no longer needed.

  • Easily mock/replace implementations for testing

    We expect that we will be able to easily substitute the real dependencies of an object with test-specific dependencies, known as mocks or test doubles. This can help isolate the behavior of an object or function during testing and ensure that it is functioning correctly.

  • Detect dependency cycles

    We expect that it can identify situations where two or more objects have circular dependencies, where each object depends on another object in the cycle. This can be a problem because it can lead to infinite loops or other errors that can cause the application to crash. The larger an application grows, the more crucial it becomes.

Using the Factory pattern gives us only 2 out of 5 required features:

  • ✅ Compile-time safety

  • ✅ Laziness

  • ❌ Control the scope/lifecycle

  • ❌ Easily mock/replace implementations for testing

  • ❌ Detect dependency cycles

How can we achieve all of them?

One option is to implement a custom solution, but a more straightforward approach is to use a ready-to-go solution. What if I tell you that the solution is a Factory again, but not the pattern but the library? - Factory library. Created in 2022, this library takes the factory pattern to the next level which I would call Factory pattern on steroids.

How to use Factory library

A simple example of using Factory:

extension Container {
    var service1: Factory<Service1> { 
        self { ServiceImpl1() }
            .singleton
    }
    var service2: Factory<Service2> { 
        self { ServiceImpl2(service1: self.service1()) }
    }
}
// you can use it like that later for DI 
let vm = ScreenViewModel(service1: Container.shared.service1(),
                         service2: Container.shared.service2())

Looks pretty and simple, right? You declare your dependencies as properties of the Container. You wrap your actual dependency into Factory type and simply address this property with () later to resolve the instance. It works due to the callAsFunction feature introduced in Swift 5.2.

Features

Let’s check our requirements list again for the Factory library:

  • ✅ Compile-time safety

    • If you declare a Factory property, you can be certain that you will be able to resolve the instance.
  • ✅ Laziness

    • As you can see, we used calculated properties there, that gives as laziness.
  • ✅ Control the lifecycle/scope

    • Factory provides us with 5 scopes: Singleton, Cached, Shared, Graph, Unique.

    • You can create your own custom scopes.

    • You can reset individual scope caches.

  • ✅ Using mocks for testing is so easy

    • Just register the mock to update the original instance
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            let _ = Container.myService.register { MockService2() }
            ContentView()
        }
    }
  • ✅ Detect dependency cycles

    • Factory provides the ability to catch dependency graph cycles but only in runtime. When your dependency will be resolved more than dependencyChainTestMax = 8 times during one resolution cycle, the library will trigger the fatal error. Max value can be changed if needed.

On top of that, Factory is lightweight and easy to understand. It also provides cool InjectedObject property wrapper for SwiftUI views if you use it in your project.

What is under the hood?

As you noticed we have a Factory wrapper for the closure that creates the dependency instance as a result. It provides us with some syntactic sugar. We simply ask the enclosing container to make a properly bound Factory for us using self.callAsFunction { ... }.

extension Container {
    var sugared: Factory<MyServiceType> { 
        self { MyService() }
    }
    var formal: Factory<MyServiceType> { 
        Factory(self) { MyService() }
    }
    // result is the same for both properties
}

The Container is basically a namespace for the factory properties you can create in the extension.

Container owns the ContainerManager instance that manages the registration and scope caching storage mechanisms for a given container.

Factory is just a wrapper for FactoryRegistration instance inside it.

FactoryRegistration is used internally to manage the registration and resolution process.

public struct Factory<T>: FactoryModifying {
    public init(_ container: ManagedContainer, 
                key: String = #function, 
                _ factory: @escaping () -> T) {
        self.registration = FactoryRegistration<Void,T>(id: "\(key)<\(T.self)>",
                                                        container: container, 
                                                        factory: factory)
    }
}

As you can see in code, registration key is a concatenation of function name (property name in this case) and dependency type.

FactoryRegistration uses the corresponding scope to resolve the dependency. Scopes are basically different types of caches (Each scope has its own level of caching).

public struct FactoryRegistration<P,T> {
    internal func resolve(with parameters: P) -> T {
        ...
        return scope?.resolve(
            using: manager.cache, 
            id: id, 
            factory: { factory(parameters) }
        ) ?? factory(parameters)
    }
}

Scope stores factory closures in dictionaries by registration keys:

public class Scope {
    internal func resolve<T>(using cache: Cache, id: String, factory: () -> T) -> T {
        if let cached: T = unboxed(box: cache.value(forKey: id)) {
            return cached
        }
        let instance = factory()
        if let box = box(instance) {
            cache.set(value: box, forKey: id)
        }
        return instance
    }
}
extension Scope {
    final class Cache {
        typealias CacheMap = [String: AnyBox] // Dictionary for reflection
        func value(forKey key: String) -> AnyBox? {
            cache[key]
        }
        func set(value: AnyBox, forKey key: String)  {
            cache[key] = value
        }
    }
}

Factory implementation idea is quite similar to reflection based libraries like Swinject. Reflection means mapping a set of keys to a set of objects. So, for example, Factory and Swinject both use a Dictionary to map the keys (instance types) to values of instances.

Factory vs. Swinject

Factory library is similar to Swinject in terms of how it works under the hood. It is a reflection-based library.

But why then Factory is more preferable than Swinject?

The main reason to prefer Factory over Swinject is compile-time safety.

Swinject fully provides all other features that we expect from DI solution except compile-time safety and it is crucial. Swinject resolves instances on demand at runtime with no safety to avoid crashes.

On the contrary, Factory is compile-time safe.

There is a big difference in the approach of how they register and resolve instances.

When Swinject resolves the instance there is no guarantee that this instance was registered before that.

With Factory when you call the property as a function to resolve the instance, property declaration is already a guarantee that the instance was registered. This is why it is compile-time safe.

Recap

To summarize, using the Factory pattern can be a suitable solution for Dependency Injection in smaller iOS applications, as it provides a simple and lightweight way to handle DI without external third-party libraries or frameworks. However, for larger and more complex projects, managing dependencies through factories can become challenging, and it may not be enough to fulfill all the requirements of proper DI. While factories can ensure compile-time safety and laziness, they cannot control the scope or lifecycle of dependencies, nor easily mock or replace implementations for testing, nor detect dependency cycles. To address these issues, one can use a ready-to-go solution such as the Factory library, which can provide all the required features.

Factory pattern

  • ✅ Compile-time safety

  • ✅ Laziness

  • ❌ Control the lifecycle (Control the scope/ easily reset scope when needed - scopes which allow for better memory management)

  • ❌ Easily mock/replace implementations for testing

  • ❌ Detect dependency cycles

Swinject

  • ❌ Compile-time safety

  • ✅ Laziness

  • ✅ Control the lifecycle

  • ✅ Easily mock/replace implementations for testing

  • ✅ Detect dependency cycles (but only at runtime)

Factory library - nice new approach to DI for Swift and SwiftUI

  • ✅ Compile-time safety

  • ✅ Laziness

  • ✅ Control the lifecycle

  • ✅ Easily mock/replace implementations for testing

  • ✅ Detect dependency cycles (but only at runtime)

Thank you for reading this post, hope it was useful.

Did you find this article valuable?

Support Vitaly Batrakov by becoming a sponsor. Any amount is appreciated!