Avoiding singletons in Swift
"I know singletons are bad, but...", is something that developers often say when discussing code. There seems to almost be a consensus in the community that singletons are "bad", but at the same time both Apple and third party Swift developers keep using them both internally within apps and in shared frameworks.
This week, let's take a look at exactly what the problems are with using singletons and explore some techniques that can be used to avoid them. Let's dive right in!
Why are singletons so popular?
First off, let's start by asking why singletons are so popular to begin with. If most developers agree that they should be avoided, why do they keep popping up?
I think the answer has two parts to it. First, I think a major reason that the singleton pattern has been used so much when writing apps for Apple's platforms, is that Apple themselves use it a lot. As third party developers, we often look to Apple to define the "best practices" for their platforms, and any pattern that they commonly use often becomes very widely spread in the community as well.
The second part of the puzzle, I think, is convenience. Singletons can often act as a shortcut for accessing certain core values or objects, since they are essentially accessible from anywhere. Just take a look at this example where we want to display the currently logged in user's name in a ProfileViewController
, as well as to log the user out when a button is tapped:
class ProfileViewController: UIViewController {
private lazy var nameLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = UserManager.shared.currentUser?.name
}
private func handleLogOutButtonTap() {
UserManager.shared.logOut()
}
}
Doing something like the above - encapsulating user & account handling features in a UserManager
singleton - is indeed very convenient (and very common!). So what exactly is so bad about using this pattern? 🤔
What's so bad about singletons?
When discussing things like patterns and architecture, it's easy to fall into the trap of being a bit too theoretical. While it's nice to have our code be theoretically "correct" and to follow all the best practices and principles - reality often hits and we need to find some sort of middle ground.
So what concrete problems are singletons usually causing, and why should they be avoided? The three main reasons why I tend to avoid singletons are:
- They are global mutable shared state. Their state is automatically shared across the entire app, and bugs can often start occurring when that state changes unexpectedly.
- The relationships between singletons and the code that depends on them is usually not very well defined. Since singletons are so convenient and easy to access - using them extensively usually leads to very hard to maintain "spaghetti code" that doesn't have clear separations between objects.
- Managing their lifecycle can be tricky. Since singletons are alive during the entire lifespan of an application, managing them can be really hard, and they usually have to rely on optionals to keep track of values. This also makes code that relies on singletons really hard to test, since you can't easily start from a "clean slate" in each test case.
In our ProfileViewController
example from before, we can already see signs of these 3 problems. It's very unclear that it depends on UserManager
, and it has to access currentUser
as an optional because we have no way of getting a compile-time guarantee that the data is actually there at the time the view controller is being presented. Sounds like bugs just waiting to happen 😬!
Dependency injection
Instead of having ProfileViewController
access its dependencies as singletons, we will instead inject them in its initializer. Here we are injecting the current User
as a non-optional, as well as a LogOutService
that can be used to perform the logout action:
class ProfileViewController: UIViewController {
private let user: User
private let logOutService: LogOutService
private lazy var nameLabel = UILabel()
init(user: User, logOutService: LogOutService) {
self.user = user
self.logOutService = logOutService
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = user.name
}
private func handleLogOutButtonTap() {
logOutService.logOut()
}
}
The result is a lot more clear and easier to manage. Our code can now safely rely on its model always being there, and it has a clear API to interact with for logging out. In general, refactoring various singletons & managers into clearly separated services is a nice way to create more clear relationships between the core objects of an app.
Services
As an example, let's take a closer look at how LogOutService
could be implemented. It also uses dependency injection for its underlying services, and provides a nice and clearly defined API for only doing a single thing - logging out.
class LogOutService {
private let user: User
private let networkService: NetworkService
private let navigationService: NavigationService
init(user: User,
networkService: NetworkService,
navigationService: NavigationService) {
self.user = user
self.networkService = networkService
self.navigationService = navigationService
}
func logOut() {
networkService.request(.logout(user)) { [weak self] in
self?.navigationService.showLoginScreen()
}
}
}
Retrofitting
Going from a setup that heavily uses singletons into one that fully utilizes services, dependency injection and local state can be really tricky and time consuming. It can also be really hard to justify spending time on, and might sometimes require a huge refactor to even be possible.
Thankfully, we can apply a similar technique as the one used in "Testing Swift code that uses system singletons in 3 easy steps", which will allow us to start moving away from singletons in a much easier way. Like in so many other situations - protocols come to the rescue!
Instead of refactoring all of our singletons at once and creating new service classes, we can simply define our services as protocols, like this:
protocol LogOutService {
func logOut()
}
protocol NetworkService {
func request(_ endpoint: Endpoint, completionHandler: @escaping () -> Void)
}
protocol NavigationService {
func showLoginScreen()
func showProfile(for user: User)
...
}
We can then easily "retrofit" our singletons as services by making them conforming to our new services protocols. In many situations we won't even need to make any implementation changes, and can simply pass their shared
instance as a service.
The same technique can also be used to retrofit other core objects in our app that we might have been using in a "singleton-like" way, such as using the AppDelegate
for navigation.
extension UserManager: LoginService, LogOutService {}
extension AppDelegate: NavigationService {
func showLoginScreen() {
navigationController.viewControllers = [
LoginViewController(
loginService: UserManager.shared,
navigationService: self
)
]
}
func showProfile(for user: User) {
let viewController = ProfileViewController(
user: user,
logOutService: UserManager.shared
)
navigationController.pushViewController(viewController, animated: true)
}
}
We can now start making all of our view controllers "singleton free" by using dependency injection & services, without having to make huge refactors and rewrites up-front 🎉! We can then start replacing our singletons with services & other types of APIs one by one, for example using the technique from "Replacing legacy code using Swift protocols".
Conclusion
Singletons are not universally bad, but in many situations they come with a set of problems that can be avoided by creating more well-defined relationships between your objects and by using dependency injection.
If you are working on an app that currently makes heavy use of singletons and you have been experiencing some of the bugs that they usually cause, hopefully this post has given you some inspiration as to how you can start moving away from them in a non-disruptive way.
What do you think, will you start refactoring away your singletons, or is your app already "singleton free"? Let me know, along with any questions, comments or feedback you might have - on Twitter @johnsundell.
Thanks for reading! 🚀