avatarMichael Long

Summary

Factory is a new approach to an old problem of dependency injection in Swift, providing a compile-time safe, flexible, and performant solution.

Abstract

In this article, the author discusses the development of Factory, a new Swift dependency injection system aimed at addressing the drawbacks of previous solutions like Resolver. Factory is influenced by SwiftUI and offers several benefits, such as compile-time safety, flexibility, power, lightweight design, high performance, conciseness, testability, and free access under the MIT License. The author also provides examples of using Factory, demonstrating its mocking and testing capabilities, scopes, constructor injection, custom containers, unit tests, and reset functionalities.

Bullet points

  • Factory is a new Swift dependency injection system inspired by SwiftUI.
  • It provides compile-time safety and flexibility.
  • Factory is lightweight, fast, and easy to use.
  • It allows for mocking and testing dependencies.
  • Scopes can be used to manage object lifetimes.
  • Constructor injection is supported.
  • Custom containers can be created for segregating factories.
  • Unit tests can be set up and torn down using push and pop functions.
  • Factory is open source and available under the MIT License.

Factory: Swift Dependency Injection

A new approach to an old problem.

Why do something new?

The first dependency injection system I ever wrote was Resolver. This open source project, while quite powerful and still in use in many applications, suffers from a few drawbacks.

  1. Resolver requires pre-registration of all service factories up front.
  2. Resolver uses type inference to dynamically find and return registered services from a container.

The first issue is relatively minor. While preregistration could lead to a performance hit on application launch, in practice the process is usually quick and not normally noticable.

No, it’s the second item that’s somewhat more problematic.

Failure to find a matching type can lead to an application crash if we attempt to resolve a given type and if a matching registration is not found. In real life that isn’t really a problem as such a thing tends to be noticed and fixed rather quickly the very first time you run a unit test or when you run the application to see if your newest feature works.

But… could we do better? That question lead me on a quest for compile-time type safety. Several other projects have attempted to solve this, but I didn’t want to have to add a source code scanning and generation step to my build process, nor did I want to give up a lot of the control and flexibility inherent in a run-time-based system.

I also wanted something simple, fast, clean, and easy to use.

Could I have my cake and eat it too?

Let’s find out.

Note: This article assumes you’re familiar with dependency injection and its concepts. If not, you might want to read the Gentle Approach to Dependency Injection guide I wrote for Resolver.

Factory

Factory is strongly influenced by SwiftUI, and in my opinion is highly suited for use in that environment. Factory is…

  • Safe: Factory is compile-time safe; a factory for a given type *must* exist or the code simply will not compile.
  • Flexible: It’s easy to override dependencies at runtime and for use in SwiftUI Previews.
  • Powerful: Like Resolver, Factory supports application, cached, shared, and custom scopes, customer containers, arguments, decorators, and more.
  • Lightweight: With all of that Factory is slim and trim, coming in under 300 lines of code. (Less than a third of the size of Resolver.)
  • Performant: No setup time is needed for the vast majority of your services, resolutions are extremely fast, and no compile-time scripts or build phases are needed.
  • Concise: Defining a registration usually takes just a single line of code.
  • Tested: Unit tests ensure correct operation of registrations, resolutions, and scopes.
  • Free: Factory is free and open source under the MIT License.

Sound too good to be true? Let’s take a look.

A simple example

Most container-based dependency injection systems require you to define in some way that a given service type is available for injection and many reqire some sort of factory or mechanism that will provide a new instance of the service when needed.

Factory is no exception. Here’s a simple dependency registraion.

extension Container {
    static let myService = Factory { MyService() as MyServiceType }
}

Unlike Resolver which often requires defining a plethora of registration functions, or SwiftUI, where defining a new environment variable requires creating a new EnvironmentKey and adding additional getters and setters, here we simply add a new static Factory to the default container. When an instance of our object is needed the factory closure is evaluated and returns an instance of our dependency. That’s it.

Using the service where needed is equally straightforward. Here’s one way to do it.

class ContentViewModel: ObservableObject {
    @Injected(Container.myService) private var myService
    ...
}

Here our view model uses an @Injected property wrapper to request the desired dependency. Similar to @EnvironmentObject in SwiftUI, we provide the property wrapper with a reference to a factory of the desired type and it handles the rest.

And that’s the core mechanism. In order to use the property wrapper you must define a factory. That factory must return the desired type when asked. Fail to do either one and the code will simply not compile. As such, Factory is compile-time safe.

What’s a Factory?

A Factory is a lightweight struct that manages a given dependency. And due to the lazy nature of static variables, a factory isn’t instantiated until it’s referenced for the first time.

When a factory is evaluated it provides an instance of the desired dependency. Note that it’s also possible to bypass the property wrapper and call the factory directly.

class ContentViewModel: ObservableObject {
    // dependencies
    private let myService = Container.myService()
    private let eventLogger = Container.eventLogger()
    ...
}

You can reference the container directly or use the property wrapper if you prefer, but either way for clarity I’d suggest grouping all of a given object’s dependencies in a single place at the top of your class. Oh, and mark them as private as well.

Mocking and Testing

Examining the above code, one might wonder why we’ve gone to all of this trouble? Why not simply say let myService = MyService() and be done with it?

Or keep the container idea, but write something similar to this…

extension Container {
    static var myService: MyServiceType { MyService() }
}

Well, the primary benefit one gains from using a container-based dependency injection system is that we’re able to change the behavior of the system as needed and if you hard code the dependency we can’t do that.

Consider the following code:

struct ContentView: View {
    @StateObject var model = ContentViewModel1()
    var body: some View {
        Text(model.text())
            .padding()
    }
}

Our ContentView uses our view model, which is assigned to a StateObject. Great. But now we want to preview our code. How do we change the behavior of ContentViewModel so that its MyService dependency isn’t making live API calls during development?

It’s easy. Just replace MyService with a mock.

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let _ = Container.myService.register { MockService2() }
        ContentView()
    }
}

Note the line in our preview code where we’re gone back to our container and registered a new closure on our factory. This function overrides the default factory closure.

Now when our preview is displayed ContentView creates a ContentViewModel which in turn depends on myService using the Injected property wrapper. But when the property wrapper calls the factory to ask for an instance of MyServiceType it now receives a MockService2 instance instead of the MyService instance originally defined.

We can do this because we originally cast the result of the myService factory to be the protocol MyServiceType. And since MockService2 conforms to the MyServiceType protocol, we’re good and we can replace one with the other.

extension Container {
    static let myService = Factory { MyService() as MyServiceType }
}

If not specialized, the type of the factory is inferred to be the type returned by the factory closure. You could also get the same result from specializing the generic Factory as shown below. Both are equivalent.

extension Container {
    static let myService = Factory<MyServiceType> { MyService() }
}

One additional thing to notice is that the result of our registration block must also conform to the type of the original factory. If it’s not and if you try to return something else Swift will complain about it and give you an error. In short, this too is compile-time safe.

If we have several mocks that we use all of the time, we can also add a setup function to the container to make this easier.

extension Container {
    static func setupMocks() {
        myService.register { MockServiceN(4) }
        sharedService.register { MockService2() }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        let _ = Container.setupMocks()
        ContentView()
    }
}

This is a powerful concept that lets us reach deep into a chain of dependencies and alter the behavior of a system as needed.

But Factory has a few more tricks up it’s sleeve.

Scopes

If you’ve used Resolver or some other dependency injection system before then you’ve probably experienced the benefits and power of scopes.

And if not, the concept is easy to understand:

Just how long should an instance of an object live?

You’ve no doubt created a singleton in your apps at some point in your career. This is an example of a scope. A single instance is created and used and shared by all of methods and functions in the app.

This can be done in Factory simply by adding a scope attribute.

extension Container {
    static let someService = Factory(scope: .singleton) { 
        SomeService() 
    }
}

Now whenever someone requests an instance of someService they’ll get the same instance of the object as everyone else.

Unless altered, the default scope is unique; every time the factory is asked for an instance of an object it will get a new instance of that object.

Other common scopes are cached and shared. Cached items are saved until the cache is reset, while shared items persist just as long as someone holds a strong reference to them. When the last reference goes away, the weakly held shared reference also goes away.

You can also add your own special purpose scopes to the mix.

extension Container.Scope {
    static var session = Cached()
}
extension Container {
    static let authentication = Factory(scope: .session) { 
        Authentication()
    }
}

Once created, a single instance of Authentication will be provided to anyone that needs one… up until the point where the session scope is reset, perhaps by a user logging out.

func logout() {
    Container.Scope.session.reset()
    ...
}

Scopes are powerful tools to have in your arsenal. Use them.

Constructor Injection

At times we might prefer (or need) to use a technique known as constructor injection where dependencies are provided to an object upon initialization.

That’s easy to do in Factory. Here we have a service that depends on an instance of MyServiceType, which we defined earlier.

extension Container {
    static let constructedService = Factory {
       MyConstructedService(service: myService())
    }
}

All of the factories in a container are visible to other factories in a container. Just call the needed factory as a function and the dependency will be provided.

Custom Containers

In a large project you might want to segregate factories into additional, smaller containers.

class OrderContainer: SharedContainer {
    static let optionalService = Factory<SimpleService?> { 
        nil
    }
    static let constructedService = Factory { 
        MyConstructedService(service: myServiceType()) 
    }
    static let additionalService = Factory(scope: .session) { 
        SimpleService()
    }
}

Just define a new container derived from SharedContainer and add your factories there. You can have as many as you wish, and even derive other containers from your own.

While a container tree makes dependency resolutions easier, don’t forget that if need be you can reach across containers simply by specifying the full container.factory path.

class PaymentsContainer: SharedContainer {
    static let anotherService = Factory {
       AnotherService(OrderContainer.optionalService())
    }
}

SharedContainer

Note that you can also add your own factories to SharedContainer. Anything added there will be visible on every container in the system.

extension SharedContainer {
    static let api = Factory<APIServiceType> { APIService() }
}

Unit Tests

Factory also has some provisions added to make unit testing eaiser. In your unit test setUp function you can *push* the current state of the registration system and then register and test anything you want.

Then in your tearDown function simply *pop* your changes to restore everything back to the way it was prior to running that test suite.

final class FactoryCoreTests: XCTestCase {
    override func setUp() {
        super.setUp()
        Container.Registrations.push()
     }
    override func tearDown() {
        super.tearDown()
        Container.Registrations.pop()
    }
    
    ...
}

Reset

You can also reset a registration to bring back the original factory closure. Or, if desired, you can reset everything back to square one with a single command.

Container.myService.reset() // single
Container.Registrations.reset() // all

Open Source

As mentioned above, Factory is free and open sourced under the MIT License.

Source code, a small demonstration app, and unit tests are available under the Factory repository on GitHub.

Resolver

Factory will probably mark the end of Resolver. I learned a lot from that project, and it even won me an Open Source Peer Bonus from Google. (I always thought it a bit strange for an iOS developer to get an award from Google, but there you have it.)

But Factory is smaller, faster, cleaner and all in all a much better solution than Resolver could ever be.

Completion Block

So that’s it. As always, let me know what you think in the comments below.

Seriously. Medium has changed their promotion and payout algorithms, so clapping and leaving comments makes a HUGE difference. Thanks!

This article is part of The Swift Dependency Injection Series.

Programming
iOS
Swift
Software Engineering
Open Source
Recommended from ReadMedium