Offer to come, dig friends take it! I am participating in the 2022 Spring Recruit Punch card activity. Click here for details.

In previous articles, we looked at some different ways to use dependency injection to achieve more decoupling and testable architectures in Swift applications. Examples include combining dependency injection with the factory pattern in “Using factory dependency injection in Swift” and replacing simple benefits with dependency injection in “Avoiding singletons in Swift”.

Most of my articles and examples so far have used initializers based dependency injection. However, like most programming techniques, DI has a variety of “Flavors,” each with its own advantages and disadvantages. This week, let’s take a look at three different approaches to dependency injection and how they are used in Swift.

Based on the initializer

Let’s start with a quick review of the most common method of dependency injection — initializers based dependency injection, which means that an object should be given the dependencies it needs when it is initialized. The biggest benefit of this approach is that it ensures that our objects have everything they need to work immediately.

Suppose we are building a FileLoader that loads files from disk. To do this, it uses two dependencies — a system-provided instance of FileManager and a Cache. Using initialitation-based dependency injection, it can be implemented as follows:

class FileLoader {
    private let fileManager: FileManager
    private let cache: Cache

    init(fileManager: FileManager = .default,
         cache: Cache = .init()) {
        self.fileManager = fileManager
        self.cache = cache
    }
}
Copy the code

Note how the default parameters are used above to avoid creating dependencies all the time when singletons or new instances are used. This allows us to simply create a FileLoader using FileLoader() in production code, while still being able to test by injecting mock data or explicit instances into test code.

Based on the attribute

While initialitation-based dependency injection is usually a good fit for your own custom classes, it can sometimes be a little difficult to use when you have to inherit from system classes. One example is when building view controllers, especially if you define them using XIBs or Storyboards, because then you no longer have control over your class’s initializer.

For these types of cases, property-based dependency injection can be a good choice. Instead of injecting the object’s dependencies in the object’s initializer, you can simply assign them later. This dependency injection approach can also help you reduce template files, especially if there is a good default value that doesn’t necessarily need to be injected.

Let’s look at another example — in this case, we want to build a PhotoEditorViewController, allow the user to edit a picture in their library. To work, the view controller needs an instance of the system-supplied PHPhotoLibrary class (which is a singleton) and an instance of our own PhotoEditorEngine class. To implement dependency injection without a custom initializer, we can create two mutable properties with default values, like this:

class PhotoEditorViewController: UIViewController {
    var library: PhotoLibrary = PHPhotoLibrary.shared()
    var engine = PhotoEditorEngine()}Copy the code

Notice how * the technique in “testing Swift code using system singletons in 3 simple steps “* provides a more abstract PhotoLibrary interface to the system PhotoLibrary class by using protocols. This will make testing and data simulation much easier!

The advantage of this approach is that we can still easily inject mock data into our tests by reassigning the view controller’s properties:

class PhotoEditorViewControllerTests: XCTestCase {
    func testApplyingBlackAndWhiteFilter(a) {
        let viewController = PhotoEditorViewController(a)// Assign a mock photo library to have full control over which photos are stored in it
        let library = PhotoLibraryMock()
        library.photos = [TestPhotoFactory.photoWithColor(.red)]
        viewController.library = library

        // Run our test command
        viewController.selectPhoto(atIndex: 0)
        viewController.apply(filter: .blackAndWhite)
        viewController.savePhoto()

        // Assert that the result is correct
        XCTAssertTrue(photoIsBlackAndWhite(library.photos[0))}}Copy the code

Based on the parameter

Finally, let’s look at parameter-based dependency injection. This type is especially useful when you want to easily make legacy code easier to test without having to change its existing structure too much.

Many times, we only need a particular dependency once, or we only need to simulate it under certain conditions. Instead of changing the object’s initializer or exposing properties to be mutable (not always a good idea), we can open up an API to accept a dependency as a parameter.

Let’s take a look at a NoteManager class that is part of a note-taking application. Its job is to manage all notes written by the user and provide an API for searching notes based on queries. Since this is an operation that may take a while (which is quite possible if the user has a lot of notes), we usually perform it in a background queue, like this:

class NoteManager {
    func loadNotes(matching query: String.completionHandler: @escaping ([Note]) -> Void) {
        DispatchQueue.global(qos: .userInitiated).async {
            let database = self.loadDatabase()
            let notes = database.filter { note in
                return note.matches(query: query)
            }

            completionHandler(notes)
        }
    }
}
Copy the code

While the above approach is a good solution for our production code, in testing we generally want to avoid asynchronous code and parallelism as much as possible to avoid patchy behavior. While it would be nice to use an initializer or attribute-based dependency injection to specify explicit queues that NoteManager should always use, this would probably require major changes to the class that we can’t/won’t do yet.

This is where parameter-based dependency injection comes in. Instead of refactoring our entire class, we can directly inject which queue to run the loadNotes operation on:

class NoteManager {
    func loadNotes(matching query: String.on queue: DispatchQueue = .global(qos: .userInitiated),
                   completionHandler: @escaping ([Note]) -> Void) {
        queue.async {
            let database = self.loadDatabase()
            let notes = database.filter { note in
                return note.matches(query: query)
            }

            completionHandler(notes)
        }
    }
}
Copy the code

This allows us to easily use a custom queue in our test code on which we can wait. This almost allowed us to turn the above API into a synchronous API in our tests, which made things easier and more predictable.

Another use case for parameter-based dependency injection is when you want to test static apis. For static apis, we don’t have initializers, and it’s best not to keep any state statically, so parameter-based dependency injection becomes a good choice. Let’s look at a static MessageSender class that currently relies on singletons:

class MessageSender {
    static func send(_ message: Message.to user: User) throws {
        Database.shared.insert(message)

        let data: Data = try wrap(message)
        let endpoint = Endpoint.sendMessage(to: user)
        NetworkManager.shared.post(data, to: endpoint.url)
    }
}
Copy the code

While the ideal long-term solution would probably be to refactor MessageSender to make it non-static and inject it correctly wherever it is used, for testing purposes (for example, to reproduce/verify an error), we can simply inject its dependencies as parameters instead of relying on singletons:

class MessageSender {
    static func send(_ message: Message.to user: User.database: Database = .shared,
                     networkManager: NetworkManager = .shared) throws {
        database.insert(message)

        let data: Data = try wrap(message)
        let endpoint = Endpoint.sendMessage(to: user)
        networkManager.post(data, to: endpoint.url)
    }
}
Copy the code

Again, we use the default parameters, except for convenience reasons, but more importantly here to be able to add testing support to our code while still maintaining 100% backward compatibility 👍.

Thank you for reading 🚀