Singletons as dependency injection

FEBRUARY 6, 2020

This is a weird concept for me to accept, normally I avoid singletons as much as possible because…

I have been going through the videos the guys at pointfree.co have been putting out (the first 3 videos are free). Their whole series revolves around programming in a more functional way and I’ve been really enjoying learning about their programming beliefs.

One of the weirder concepts they talked about was using a singleton to do dependency injection. At first I was completely against this idea, but as they went through explaining their reasoning I started to see the benefits. Instead of brushing off the idea I decided to give it a shot and see if I can find any problems with this approach.

So why are singletons bad?

There are many many resources on the internet already discussing this so I’m not going to go into every reason, I’m just going to list my personal reasons.

#1: Singletons make testing hard.

The point of singleton is to be globally accessible. When we write our tests it becomes very difficult if not impossible to write tests to verify behavior because state is shared. Let’s say we have a singleton for Core Data called DatabaseManager.shared. When we go test a view controller that loads and saves models from the database we start to run into problems. Each of our tests will be modifying the app’s real core data store through DatabaseManager.shared, which we don’t want.

So instead of using in our DatabaseManager.shared directly inside of our class we inject it, maybe with an intitalizer like:

init(database: DataManageable = DatabaseManager.shared)

This means we need to create a new protocol DataManageable that DatabaseManager conforms to. This allows us to create mocks that also conform to DataManageable, which we can inject into our tests.

SomeClass(database: MockDatabase())

So by using a protocol we can now inject mocks for our tests and we are no longer hitting the real core data store.

However this has some downsides.

Downside 1: a proliferation of protocols. Apple has said Swift is a “Protocol Oriented Language” and this is certainly true. However, creating protocols just for mocking and testing seems a bit wrong. Yeah, protocols are helpful because we can later swap out our dependencies for anything that conforms to the protocol. But how likely is it that we will swap out our DatabaseManager.shared for RealmDatabaseManager.shared and the protocol will magically work? Probably not very likely, unless we spent a lot of time making the protocol very abstract, which might not be worth our time.

Downside 2: Not everyone on the team will be on board with dependency injection. There’s no denying that a singleton is faster and easier to use when creating new features, not that this is correct and can lead to hard to track bugs, but singletons are fast to work with. Sometimes there really is no time to worry about properly creating protocols and passing them through multiple classes just because some view deep in a flow needs to have access to some dependency. I’m fully on board with trying to architecture our code in a “correct” way but I also realize that under a time crunch that goes out the window and you just get it done and hope nothing breaks.

Downside 3: It turns people off of testing. In my experience most teams in the iOS community do not write tests, or if they do it is very minimal. It really depends on the culture of the company. A lot of times the requirements for a feature change very quickly, so the feature that was started may be drastically different than the end product. This means that worrying about updating protocols and mocks every time a requirement changes can become tedious, and eventually the tests become more of a burden than helpful.

#2 Singletons become a God class

At first a singleton might not be that bad. It might have been created to do a very isolated piece of work and it is easy to understand what the singleton is used for.

However it is very easy to abuse singletons. Over time things may start to get added on to the singleton because it’s easier to do than structuring code in a cleaner way.

For example let’s say we have NavigationManager.shared singleton that is responsible for navigating between all our view controllers. At first this starts off okay, all the logic is simple and simply pushes and pops view controllers like a navigation controller does. Then we find it would be easier to navigation from ViewControllerA to ViewControllerB if we just make a function in NavigationManager.shared:

func pushViewControllerB() {
    let vc = ViewControllerB() 
    self.push(vc)
}

Down the road ViewControllerB adds a new feature and we now need to provide it that viewControllerB has a dependency that needs to be provided:

func pushViewControllerB() {
    let vc = ViewControllerB(userInfo: ???) 
    self.push(vc)
}

How do we supply the userInfo dependency?

We can update the pushViewControllerB() function

func pushViewControllerB(userInfo: UserInfo) {
    let vc = ViewControllerB(userInfo: userInfo) 
    self.push(vc)
}

But now every caller of the original function needs to be updated to provide a UserInfo dependency. However, those callers might not have any access to UserInfo, and the whole point of the original function was to make it easy to push ViewControllerB from any screen. Let’s keep things ‘easy.’

So instead we add a new function to NavigationManager.shared that fetches the UserInfo from somewhere and then pushes ViewControllerB:

func pushViewControllerB() {
    fetchUserInfo(completion: { (userInfo) in 
      let vc = ViewControllerB(userInfo: userInfo) 
      self.push(vc)
    }
}

func fetchUserInfo(completion: @escaping (UserInfo) -> Void) {
    // does some fetching 
    completion(userInfo)
}

Now NavigationManager.shared is doing a lot more than it was originally set up to do. It’s fetching userInfo and then pushing a view controller. This will keep growing and growing as more and more ‘helpful’ functions are created to easily push view controllers.

Now if we every refactor away from using NavigationManager.shared it becomes very difficult because now we also need to pull out and refactor all of the work it was doing behind the scenes like fetching users.

This is a bit of a trivial example and there are ways of avoiding this issue. But I’ve seen many times singletons being abused for the sake of ease of use which leads to God classes.

#3 Singletons are hard to track down

In an app there can be any number of singletons, DatabaseManager.shared, NetworkManager.shared, NavigationManager.shared. When they are used within a class it becomes impossible to know the dependencies of that class. We don’t know if a specific class is using a singleton unless we go in and look at the code.

We can search for .shared and check what classes are using them, but not all singletons use the .shared pattern. Some might be .sharedInstance or .current, etc.

It becomes difficult to know what classes are depending on a singleton

A new approach with Current

So we’ve seen some reasons why singletons are bad, so how/why would we use them to do dependency injection?

The idea behind Current is to be a singleton for holding dependencies.

Lets pretend we have a protocol DatabaseManageable:

protocol DatabaseManageable {
    func getUserForId(_ id: Int64) -> User?
}

Scattered throughout our app we would be hitting DatabaseManager.shared.getUserForId(userId:).

Let’s see how to do this using Current:

struct Environment {
  var getUserForId: (Int64) -> User? 
}

extension Environment {
  static let live = Environment(getUserForId: { (userId) in 
    return DatabaseManager.shared.getUserForId(userId)
  }
}

#if DEBUG
extension Environment {
  static let mock = Environment(getUserForId: { (_) in 
    return User() 
  }
}
var Current = Environment.mock 
#else 
let Current = Environment.live 
#endif 

So this looks like a lot but it’s really simple. Current is a singleton representing the Environment struct.

Environment holds all of our dependencies and doesn’t hold any state, it simply delegates along to other dependencies to perform the work. It’s an abstraction layer.

Now we would do:

Current.getUserForId(1337)
~ Instead of ~
DatabaseManager.shared.getUserForId(userId: 1337)

How does Current solve my 3 problems?

My 3 issues with singletons were being hard to test, becoming a God class, and being hard to track dependencies. I’ll explain how Current solves those issues now.

#1 Current makes testing trivial

Since current is mutable when testing or debugging we can easily modify it’s behavior.

Let’s say we want to write a test to see that a view controller fetches a user when it first loads

Current = .mock 
let vc = SomeViewController()
var didCallGetUserId = false 
Current.getUserForId = { (_) in
  didCallGetUserId = true  
  return nil 
}
_ = vc.view 
XCTAssert(didCallGetUserId)

We can do all kinds of test now by just reassigning Current.getUserForId before each test.

And since we wrapped Current in the DEBUG macro it can only be modified when testing or debugging and never in a RELEASE version of the app.

#2 Current does not become a God class

Since current simply delegates to it’s dependencies and doesn’t hold state or perform any actions on it’s own, it is more like a protocol than a normal singleton.

#3 Current makes finding dependencies easy

Because all of our dependencies now go through Current we can easily check when a class is using a dependency by looking for the use of Current.

Before we had many singletons doing different things sprinkled across our app. Now we have a central place that routes all of our calls to dependencies. It now becomes very easy to find where our dependencies are and we can easily swap out dependencies and not need to update our callers.

extension Environment {
  static let live = Environment(getUserForId: { (userId) in 
    if FeatureFlagDBV2.isEnabled {
       return DatabaseManagerV2.shared.getUserForId(userId)
    } else {
       return DatabaseManager.shared.getUserForId(userId)
    }
  }
}

All our callers remain the same but the dependency itself can change. If we tried to do this to before we would need to update all of our calls to DatabaseManager.shared to be DatabaseManagerV2.shared and if this was feature flagged that would be even harder to manage all over the place.

References:

https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable

https://youtu.be/V-YvI83QdMs?t=600