Singletons in Swift: Friend or Foe?
Singletons are a familiar design pattern in Swift development, often employed for their simplicity and ease of access. They provide a straightforward way to manage shared resources, like network clients or configuration settings, by ensuring a single instance is used throughout the application’s lifecycle. This approach can streamline certain aspects of development, offering a quick solution for state management. However, as applications grow in complexity, the convenience of singletons is quickly exhausted. Let’s take a look at what Singletons are, why they are often a bad choice, and some alternatives to Singletons using real examples.
What is a Singleton
Before we get into why singletons are bad we need to understand what they are. Said simply, a singleton is a class designed to only have one instance in existence. Think of it as a single point of truth that provides a globally accessible way to manage shared resources or data.
In Swift, singletons are typically implemented using a static property. Here is an example that uses a UserPreferences
singleton class to manage user configurations:
@Observable
class UserPreferences {
static let shared = UserPreferences()
private init() {}
var notificationsEnabled: Bool = true
func toggleNotificationss() {
notificationsEnabled.toggle()
}
}
In this example, the UserPreferences
class holds a boolean indicating whether notifications are enabled. The shared
instance gives us a single, accessible way to manage these settings through the app, while the private initializer private init()
prevents our code from creating any additional instances. These work together to ensure that there’s only one, consistent instance of UserPreferences
for the entire app to use.
Singletons can be useful in our logic code but it’s also common to interact with them from our UI. Here is a look at how we can access a singleton’s state in our view:
struct HomeView: View {
@State private var preferences = UserPreferences.shared
var body: some View {
VStack {
Text(preferences.notificationsEnabled ? "Notifications are enabled." : "Notifications are disabled.")
.padding()
}
}
}
In this example, we declare a State
property called preferences
which holds our singleton. Because UserPreferences
is a an Observable
, any changes to its properties will be published triggering SwiftUI to re-render the view, ensuring that the displayed status is always up to date.
Why Use a Singleton
While singletons often get a bad rap — and for good reasons — they’re not inherently a poor choice. In fact, in the right context, singletons offer a simple, effective way to manage resources or provide shared functionality that only needs a single instance across an app. Used thoughtfully, they bring a certain finesse to situations where managing multiple instances of an object would muddy things up.
“Singletons can be a good choice if we are mindful of the potential pitfalls.”
Singletons work best for utilities or resources that need a single, global access point — like configuration managers, network sessions, or application settings. They’re ideal for resources that don’t alter application state dynamically, keeping the app’s structure clean and efficient. Apple’s frameworks themselves rely on singletons for commonly used services: UserDefaults for settings, NotificationCenter for events, and URLSession for network calls. In each of these classes, Apple defines a singleton using the common .shared
scheme. Apple even provides documentation on how to implement singletons in our own code. Here is a snippet from URLSession showing the shared singleton:
@available(iOS 7.0, *)
open class URLSession : NSObject, @unchecked Sendable {
open class var shared: URLSession { get }
One of the biggest advantages of singletons is their simplicity. They’re easy to implement and avoid the need for more complex patterns like dependency injection, especially in smaller projects or when resources are limited. By maintaining a single, consistent instance across the app, singletons can also help reduce synchronization headaches in concurrent environments, making them an efficient choice when quick access to shared resources is a priority. With the right mindset and careful consideration, singletons can keep your code lean, familiar, and highly functional — giving you the best of both worlds in situations where single-instance resources are the right fit.
Why Singletons are Often a Bad Choice
While singletons might solve specific problems, they also bring considerable risks, particularly when managing state. One of the biggest challenges with singletons is that they introduce global state across the app, which can make an app’s behavior difficult to predict. Since singletons are accessible from anywhere, it becomes easy for different parts of the app to change the singleton’s state, often in ways that can’t be tracked or anticipated. As apps grow in size and complexity, these hidden dependencies create a maze of state changes that are tough to debug and can lead to unexpected, hard-to-trace bugs. Let’s take a look at a singleton that might be used to manage an app’s user preferences including a color theme:
enum Theme {
case light, dark
}
class UserPreferences {
static let shared = UserPreferences()
var theme: Theme = .light
private init() {}
func updateTheme(to newTheme: Theme) {
theme = newTheme
}
}
We have a property theme
and a method updateTheme
to mutate the property. Imagine that this theme could be set from different parts of the app — perhaps the user changes the theme in one screen, while a background task tries to reset it. If both updates hit at the same time we get a data race; it becomes impossible to know which call will take land first. In order to make this thread-safe we would need to implement a serial queue or a lock to ensure only one thread can modify the theme at a time. However, even with synchronization, the singleton itself may still lead to tight coupling which makes it difficult to test.
Because singletons provide a single, unchangeable instance across an app, any component relying on a singleton is tightly coupled to it. This makes it hard to isolate individual components in unit tests. Developers often find it challenging to mock or substitute singletons during tests without introducing additional complexity or relying on workarounds, which weakens the flexibility that’s typically valuable in testable code. With singleton-based dependencies, tests often end up brittle, where one small change to the singleton can break numerous tests across the codebase, making it harder to maintain and evolve over time. Let’s see our UserPreferences
singleton again:
class UserPreferences {
static let shared = UserPreferences()
var theme: Theme = .light
private init() {}
func updateTheme(to newTheme: Theme) {
theme = newTheme
}
}
Now, let’s see how a unit test can become brittle because of the singleton:
class UserPreferencesTests: XCTestCase {
func testThemeUpdate() {
UserPreferences.shared.updateTheme(to: .dark)
let currentTheme = UserPreferences.shared.theme
XCTAssertEqual(currentTheme, .dark)
}
func testDefaultTheme() {
XCTAssertEqual(UserPreferences.shared.theme, .light)
}
}
In this scenario, the second test fails because it expects the theme to be “Light” but the first test changed it to “Dark”. The tests are brittle because they rely on the state of the singleton, leading to potential failures and making tests less reliable. Each test should ideally be isolated and not influenced by the outcome of others, which is harder to achieve using shared mutable state in a singleton.
Concurrency is yet another concern with singletons. If multiple parts of the app access a singleton simultaneously, there’s a risk of race conditions, especially if the singleton isn’t explicitly made thread-safe. This can lead to unpredictable behavior as different threads may attempt to read or write the singleton’s data at the same time. Without careful synchronization, the code can become vulnerable to subtle bugs that are challenging to reproduce or track down, particularly in larger or multi-threaded applications. For all these reasons, singletons require careful consideration, and for many developers, alternative patterns like dependency injection or using dedicated state containers are far more reliable in delivering scalable, predictable, and testable code.
Alternatives to Singletons
When considering alternatives to singletons, one solid approach is employing dependency injection. This design pattern allows you to pass dependencies directly to a class or function rather than relying on hard-coded instances or global access points. By adopting this method, you not only enhance the flexibility of your code but also significantly improve its testability. For instance, imagine a UserPreferences class that handles theme settings in your app. Instead of having your views access this class through a singleton, you can inject it into the views that require access to user preferences. This opens the door for easy configuration changes during testing or different runtime scenarios, allowing you to tailor behaviors without modifying the core logic.
class UserPreferences {
var theme: Theme = .light
}
struct ContentView: View {
@State private var currentTheme: Theme
init(userPreferences: UserPreferences) {
self.currentTheme = userPreferences.theme
}
var body: some View {
Text("Current Theme: \(currentTheme)")
}
}
In this snippet, the UserPreferences
class defines a simple model that stores a user’s theme preference. The ContentView struct initializes with an instance of UserPreferences, allowing it to read the current theme directly. This approach eliminates the reliance on a singleton, making it clear where the theme value comes from and enabling different instances for testing purposes. If you wanted to test the ContentView, you could easily inject a mock or altered instance of UserPreferences without needing to change any underlying code, thereby enhancing testability. Recall our brittle unit tests from earlier:
class UserPreferencesTests: XCTestCase {
func testThemeUpdate() {
UserPreferences.shared.updateTheme(to: .dark)
let currentTheme = UserPreferences.shared.theme
XCTAssertEqual(currentTheme, .dark)
}
func testDefaultTheme() {
XCTAssertEqual(UserPreferences.shared.theme, .light)
}
}
Now let’s update those tests to take advantage of dependency injection:
class UserPreferencesTests: XCTestCase {
func testThemeUpdate() {
let userPreferences = UserPreferences()
userPreferences.updateTheme(to: .dark)
let currentTheme = userPreferences.theme
XCTAssertEqual(currentTheme, .dark)
}
func testDefaultTheme() {
let userPreferences = UserPreferences()
XCTAssertEqual(userPreferences.theme, .light)
}
}
The difference may seem subtle but the result is quite loud. In our updated tests we create a fresh instance of UserPreferences
in each test which means no global shared state leading to better testability. Because each test is independent there are fewer risks of side effects and a greater opportunity for flexibility. It also means that our tests are more predictable since we know our state is only being modified by the actions within that unit test.
Another compelling alternative is utilizing StateObject or ObservedObject in SwiftUI to manage shared state. This approach fully leverages SwiftUI’s reactive framework, providing seamless updates to the UI whenever the underlying state changes. By designing an observable class for your user preferences, you can bind its properties directly to your views. This setup not only simplifies your code but also enhances encapsulation, eliminating the need for global state management. With this method, tracking state changes and their impact on the UI becomes much more straightforward, leading to a cleaner, more maintainable codebase.
class UserPreferences: ObservableObject {
@Published var theme: Theme = .light
}
struct ContentView: View {
@StateObject private var userPreferences = UserPreferences()
var body: some View {
VStack {
Text("Current Theme: \(userPreferences.theme)")
Button("Toggle Theme") {
userPreferences.theme = (userPreferences.theme == .light) ? .dark: .light
}
}
}
}
In this example, the UserPreferences
class is defined as an ObservableObject
, and its theme property is marked with the Published
macro. This allows any views observing it to automatically update whenever the theme changes. The ContentView
uses StateObject
to create and manage an instance of UserPreferences
. When the button is tapped, it toggles the theme, and the UI updates dynamically to reflect this change. This reactive design replaces the need for a singleton by providing a clean, encapsulated state management system that automatically synchronizes the UI with the underlying data, making the application more resilient to bugs that can arise from shared state management issues.
Each of these alternatives provides a robust framework for managing state and dependencies in your applications, promoting better organization, testability, and clarity in your code. By moving away from singletons, you can enhance the maintainability and scalability of your applications.
Conclusion
While singletons have their place in software design, the risks they introduce — particularly in managing state and complicating testing — often outweigh their benefits. Using alternative design patterns allow for clear and direct access to resources without the global state pitfalls of singletons. These strategies not only resolve many of the inherent issues tied to singletons but also arm developers to build applications that are more adaptable and resilient to change.
Ordinary Industries
Thank you for taking the time to read this. Before you go: