What are the problems with singletons, and when are they OK?
TL;DR
A singleton is a class with one shared instance, like URLSession.shared or UserDefaults.standard.
It is easy to use, but it is global state: hidden dependencies, hard tests, and data races if it is mutable.
It is OK for truly app-wide, stateless or thread-safe services. Inject it instead of calling .shared everywhere.
How to write one in Swift
final class AnalyticsClient: Sendable {
static let shared = AnalyticsClient()
private init() {}
func track(_ event: String) { /* send */ }
}static letis lazy and thread-safe. Swift runs the initializer once, even with many threads (it usesdispatch_once-style logic).private init()stops others from creating a second instance.finalstops subclassing.Sendableis needed in Swift 6 for a global. This class has no mutable state, so it is safe.
Problem 1: hidden dependencies
final class CheckoutViewModel {
func pay() {
PaymentService.shared.charge() // hidden: not visible in init
AnalyticsClient.shared.track("pay")
}
}You cannot see what CheckoutViewModel needs by looking at its init.
Problem 2: hard to test
You cannot swap PaymentService.shared for a fake. Tests may hit real services. Tests also share state, so one test can break another.
Problem 3: shared mutable state and data races
final class SessionStore {
static let shared = SessionStore()
var token: String? // read and written from many threads = data race
}In Swift 6 language mode, this does not compile. A global static let must be Sendable or isolated to an actor.
@MainActor
final class SessionStore {
static let shared = SessionStore()
var token: String?
}
// or for background use
actor TokenCache {
static let shared = TokenCache()
private var token: String?
func set(_ t: String?) { token = t }
func get() -> String? { token }
}The fix: inject the singleton
Keep the shared instance, but pass it in. Use a protocol and a default value.
protocol Analytics {
func track(_ event: String)
}
extension AnalyticsClient: Analytics {}
final class CheckoutViewModel {
private let analytics: Analytics
init(analytics: Analytics = AnalyticsClient.shared) {
self.analytics = analytics
}
func pay() { analytics.track("pay") }
}
// Test
final class SpyAnalytics: Analytics {
var events: [String] = []
func track(_ event: String) { events.append(event) }
}
let spy = SpyAnalytics()
let model = CheckoutViewModel(analytics: spy)Production code is unchanged. Tests get full control.
When a singleton is fine
- The system already gives you one:
FileManager.default,NotificationCenter.default,UIApplication.shared. - Truly one per process: a logger, an image cache, an analytics client.
- It is stateless, or its state is protected by an actor or a lock.
Even then, inject it where you can.
Singleton vs shared instance
Apple's URLSession.shared is a shared instance, not a strict singleton. You can still create URLSession(configuration:). That is often the better design: give a convenient default, but allow more instances.
Common follow-up questions
- Is
static letthread-safe? Yes for the creation. The properties inside are not, unless you protect them. - How do you reset a singleton between tests? You should not need to. Inject a fresh instance per test instead.
- Singleton vs global variable? Almost the same problems. A singleton just groups the global behind a type.
- Can a struct be a singleton? You can have
static let sharedon a struct, but each assignment copies it. The pattern makes sense only for classes and actors.