February 14, 2019
At the most recent Swift Language User Group meetup, Patrick Barry presented a great talk about how Lyft implements dependency injection. I'd highly recommend watching the video - I was impressed by how clean and functional the solution they came up with is. I was going to write up a summary for my coworkers, but figured I might as well put it here for the benefit of anyone who's interested.
This post describes the very simple approach to dependency injection presented in the talk, using new functions bind
and mock
. I'll paraphrase some code in this write up, but the concepts and function names will match the talk.
Edit (4/30/2019): This post describes a nice Swift implementation of a pattern similar to the Service Locator Pattern, considered by many to be a strong anti-pattern. Like Singletons, you should use Service Locator carefully - I've added a section at the end about drawbacks of this approach.
I won't go into detail on what dependency injection is, since there's a fair amount of time in the talk dedicated to it. Instead, let's think about an example.
Say we're writing an app to display info about cats. We would have a Cat
model in our app:
struct Cat: Codable {
let name: String
let image: URL
}
And let's assume we have an API to fetch a cat by name:
GET /cat?id=2
{
"name": "Maggie",
"image": "https://placekitten.com/200/200"
}
From an architectural point of view, we want to extract this into two different parts:
NetworkService
which makes the requestsCatService
which exposes a getCat(id:)
method (this calls into the network service)There's a clear seam between the NetworkService
and CatService
that we can use to test CatService
. Let's extract the network's functionality into an interface:
protocol NetworkInterface {
func makeRequest(
url: URL,
completion: @escaping (Result<Data, Error>) -> Void
)
}
And the concrete implementation:
private class NetworkService: NetworkInterface {
func makeRequest(
url: URL,
completion: @escaping (Result<Data, Error>) -> Void
) {
// Use URLSession, etc
}
}
And now for the really interesting part - NetworkService
will expose itself using a special function called bind (I've added the "SimpleDI" namespace here):
let getNetwork = SimpleDI.bind(NetworkInterface.self) { NetworkService() }
bind
returns getNetwork
as a function which can be called to get a concrete implementation of the NetworkInterface
protocol. CatService
can then call getNetwork
to get access to the network:
class CatService {
func getCat(id: Int, completion: @escaping (Result<Cat, Error>) -> Void) {
let network = getNetwork()
network.makeRequest(/* ... */)
}
}
Before we talk about why bind
is useful, let's discuss the implementation. bind
returns a function which takes no parameters and returns a type of NetworkInterface
, but it also takes a closure which will be used to generate the concrete implementation.
bind
's body looks like this (again, paraphrased a bit from the talk):
private var instantiators: [String: Any] = [:]
enum SimpleDI {
static func bind<T>(
_ interfaceType: T.Type,
instantiator: @escaping () -> T
) -> () -> T {
instantiators[String(describing: interfaceType)] = instantiator
return self.instance
}
private static func instance<T>() -> T {
let key = String(describing: T.self)
let instantiator = instantiators[key] as! () -> T
return instantiator()
}
}
We take the closure that we're passed and save it in a dictionary (see end note), then return a function which accesses and calls the closure we provided. Though we have to do some force casting, we're guaranteed that the closure we need will be there when getNetworkInterface
is called, since we put it into instantiators
before returning from bind
.
Using bind
adds a level of indirection at the seam between NetworkService
and CatService
, which allows us to stub in a mock in tests. In order to do that, we need to define mock
as well, and add a bit of more infrastructure:
private var instantiators: [String: Any] = [:]
private var mockInstantiators: [String: Any] = [:]
enum SimpleDI {
static var isTestEnvironment = false
static func bind<T>(
_ type: T.Type,
instantiator: @escaping () -> T
) -> () -> T {
instantiators[String(describing: type)] = instantiator
return self.instance
}
private static func instance<T>() -> T {
let key = String(describing: T.self)
if self.isTestEnvironment {
guard let instantiator = mockInstantiators[key] as? () -> T else {
fatalError("Type \\(key) unmocked in test!")
}
return instantiator()
}
let instantiator = instantiators[key] as! () -> T
return instantiator()
}
static func mock<T>(_ type: T.Type, instantiator: @escaping () -> T) {
mockInstantiators[String(describing: type)] = instantiator
}
}
This code isn't very pretty - in fact, checks like isTestEnvironment
are generally a code smell that mean you should refactor how the class works to avoid the check. However, this enables us to write tests very easily: all we have to do is set isTestEnvironment = true
, and we'll be able to stub in a mock immediately using mock
.
Let's say we want to write a test which makes sure CatService reports an error when the underlying network errors:
class NetworkThatAlwaysErrors: NetworkInterface {
func makeRequest(url: URL, completion: @escaping (Result<Data>) -> Void) {
completion(.error(error: NSError(domain: "", code: 0, userInfo: nil)))
}
}
class CatServiceTestCase: XCTestCase {
func testCatServiceReportsError() {
SimpleDI.isTestEnvironment = true
SimpleDI.mock(NetworkInterface.self) { NetworkThatAlwaysErrors() }
let expecation = self.expectation(description: "Should return error")
let service = CatService()
service.getCatImage(named: "Maggie", completion: { result in
if case .error = result {
expecation.fulfill()
}
})
self.waitForExpectations(timeout: 0.2)
}
}
bind
and mock
take the dirty work of setting up the DI/mocking infrastructure and hide it under the rug, allowing us to write more expressive tests easily.
Some interesting things to note:
bind
brings the argument count down to 0 without sacrificing testability, and makes both the service and the test more elegant.bind
will error if you forget to mock out an interface in a test, so you're never accidentally calling deeper into dependencies than you mean to.This approach is very similar to the Service Locator Pattern, which is commonly criticized. I really enjoyed this article about Service Locator in iOS, and there are a few downsides that I should mention:
bind
means you have implicit instead of explicit dependencies. Instead of knowing explicitly that CatService
depends on NetworkService
, you have to look for the call to getNetwork
. There's a tradeoff between the simplicity of using a service locator and the fact that it can make services with many dependencies more complex to reason about.bind
, it can be tough to recognize that you can update a test when a dependency of a service is removed. If CatService
stopped calling out to the network, it's wouldn't be immediately apparent that NetworkThatAlwaysErrors
could be deleted. This can lead to unnessary cognitive overhead in tests.mock
, you have to be very careful to set up your testing framework such that all the mocks get cleared after every test. Otherwise, you might have different behavior based on the order the tests are run, which can lead to flakes.It was really interesting to see how Lyft was able to come up with such a simple solution to a complex problem. I'd really recommend watching the talk if you're interested in dependency injection and testing. If you'd like to see a working example of bind
and mock
, I've put one here (it has a few differences from the code in this post in order to demonstrate an actual working request and test).
bind
and mock
on multiple threads you'll need to implement something similar to an atomic box (the talk mentions this).mock
in your test target to make sure it can't be called in production!I'm Noah, a software developer based in the San Francisco Bay Area. I focus mainly on full stack web and iOS development