Different flavors of dependency injection in Swift
In previous posts, we've taken a look at a few different ways to use dependency injection to achieve a more decoupled and testable architecture in Swift apps. For example by combining dependency injection with the factory pattern in "Dependency injection using factories in Swift", and by replacing singletons with dependency injection in "Avoiding singletons in Swift".
So far, most of my posts and examples have used initializer-based dependency injection. However, just like with most programming techniques, there are multiple "flavors" of dependency injection - each with its own pros & cons. This week, let's take a look at three such flavors and how they can be used in Swift.
Initializer-based
Let's start with a quick recap of the most common flavor of dependency injection - initializer-based - the idea that an object should be given the dependencies it needs when being initialized. The big benefit of this flavor is that it guarantees that our objects have everything they need in order to do their work right away.
Let's say we're building a FileLoader
that loads files from disk. To do that it uses two dependencies - an instance of the system-provided FileManager
and a Cache
. Using initializer-based dependency injection, an implementation could look like this:
class FileLoader {
private let fileManager: FileManager
private let cache: Cache
init(fileManager: FileManager = .default,
cache: Cache = .init()) {
self.fileManager = fileManager
self.cache = cache
}
}
Note how default arguments are used above to avoid having to always create the dependencies when either a singleton or a new instance should be used. This enables us to simply create a file loader using FileLoader()
in our production code, while still enabling testing by injecting mocks or explicit instances in our testing code.
Property-based
While initializer-based dependency injection is usually a great fit for your own custom classes, sometimes it can be a bit hard to use when you have to inherit from a system class. An example of that is when building view controllers, especially if you are using XIBs or Storyboards to define them, since then you are no longer in control of your class' initializer.
For these types of situations, property-based dependency injection can be a great alternative. Instead of injecting an object's dependencies in its initializer, they can simply be assigned afterwards. This flavor of dependency injection can also help you reduce boilerplate, especially when there is a good default that doesn't necessarily need to be injected.
Let's take a look at another example - in which we're building a PhotoEditorViewController
that lets the user edit one of the photos from their library. To function, this view controller needs an instance of the system-provided PHPhotoLibrary
class (which is a singleton), as well as an instance of our own PhotoEditorEngine
class. To enable dependency injection without a custom initializer, we can create mutable properties that both have default values, like this:
class PhotoEditorViewController: UIViewController {
var library: PhotoLibrary = PHPhotoLibrary.shared()
var engine = PhotoEditorEngine()
}
Note how the technique from "Testing Swift code that uses system singletons in 3 easy steps" is used above to provide a more abstract PhotoLibrary
interface to the system photo library class, by using a protocol. This will make testing & mocking a lot easier!
The good thing about the above is that we can still easily inject mocks in our tests, by simply re-assigning our view controller's properties:
class PhotoEditorViewControllerTests: XCTestCase {
func testApplyingBlackAndWhiteFilter() {
let viewController = PhotoEditorViewController()
// Assign a mock photo library to gain complete control over
// what photos are stored in it
let library = PhotoLibraryMock()
library.photos = [TestPhotoFactory.photoWithColor(.red)]
viewController.library = library
// Run our testing commands
viewController.selectPhoto(atIndex: 0)
viewController.apply(filter: .blackAndWhite)
viewController.savePhoto()
// Assert that the outcome is correct
XCTAssertTrue(photoIsBlackAndWhite(library.photos[0]))
}
}
Parameter-based
Finally, let's take a look at parameter-based dependency injection. This flavor is particularly useful when you want to easily make legacy code more testable, without having to change too much of its existing structure.
Many times, we only need a specific dependency once, or we only need to mock it under certain conditions. Instead of having to change an object's initializer or expose properties as mutable (which is not always a good idea), we can open up a certain API to accept a dependency as a parameter.
Let's take a look at a NoteManager
class that's part of a note-taking app. It's job is to manage all notes that the user has written, and provides an API for searching for notes based on a query. Since this is an operation that could take a while (if the user has many notes, which is quite likely), we normally perform it on 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)
}
}
}
While the above is a great solution for our production code, in tests we normally want to avoid asynchronous code and parallelism as much as possible, in order to avoid flakiness. While it would be nice to use initializer- or property-based dependency injection to be able to specify an explicit queue that NoteManager
should always use, it may require big changes to the class that we're not able/willing to make right now.
This is where parameter-based dependency injection comes in. Instead of having to refactor our entire class, let's just make it possible to inject what 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)
}
}
}
This enables us to easily use a custom queue in our testing code, which we can wait on. This almost lets us turn the above API into a synchronous one in our tests, which makes things a lot easier and more predictable.
Another use case of parameter-based dependency injection is when you want to test static APIs. With static APIs we don't have an initializer, and we ideally shouldn't be keeping any state statically either, so parameter-based dependency injection becomes a great option. Let's take a look at a static MessageSender
class that is currently relying on singletons for its dependencies:
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)
}
}
While an ideal long-term solution here would probably be to refactor MessageSender
into being non-static and properly injected everywhere it's used, but in order to easily be able to test it (for example, in order to reproduce/verify a bug) 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)
}
}
Again we use default arguments, both as a convenience, but here more importantly to be able to add testing support to our code while still maintaining 100% backwards compatibility 👍.
Conclusion
So what flavor of dependency injection is the best one? My answer is, like in many cases, the boring one: it depends 😅. One thing that I always try to do on this blog is to present many different solutions to a given problem. The reason for this is simple - I really don't believe in silver bullets and I think having multiple tools and flavors of certain techniques at our disposal enables us to make better, more informed decisions when writing code.
I hope this post has given you some new ideas on how to apply dependency injection to your own code, and if you have another flavor that I didn't cover in this post, I'd love to hear about it. Let me know, along with any questions, comments or feedback you have - on Twitter @johnsundell.
Thanks for reading! 🚀