iosNudge

What are the problems with singletons, and when are they OK?

EasyAsked oftenArchitectureSwiftConcurrency 2 min read

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 let is lazy and thread-safe. Swift runs the initializer once, even with many threads (it uses dispatch_once-style logic).
  • private init() stops others from creating a second instance.
  • final stops subclassing.
  • Sendable is 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 let thread-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 shared on a struct, but each assignment copies it. The pattern makes sense only for classes and actors.