The cheapest comment in code review is "singletons are evil, let's remove it". It takes five seconds to type, nobody argues, and the author feels they have contributed to the architecture. Six months later the project passes Logger into every view model through the initializer, creates OkHttpClient in three different places, and still has an open bug where the previous user's cart shows up after logout. Nobody touched the cause of that bug.
Our position is plain: what hurts in large codebases is hidden access and global mutable state. The fact that one instance of something exists for the life of the process hurts nobody on its own. This article covers what actually breaks, what should stay a single instance, and how we move an existing project there without a big-bang rewrite. The running example is a hypothetical travel booking app.
"Only one" and "reachable from everywhere" are two separate decisions
The classic singleton pattern squeezes two decisions into one line:
final class SessionManager {
static let shared = SessionManager() // 1) one instance 2) anyone can reach it
private init() {}
}
The first decision is about lifetime: how many of these exist in the process and how long they live. The second is about access: who can see the object, and from where. One instance of an HTTP client that owns a connection pool is a perfectly sensible lifetime decision. The same client being reachable from an extension in the Formatters folder is an entirely different access decision, and usually one that nobody made on purpose.
Most teams that say "delete the singletons" are unhappy with the second decision and punish the first. The outcome is that things which should be unique get duplicated, and hidden access comes back under another name (a service locator, a global Container.resolve()). We cover that second picture in DI anti-patterns on iOS.
Instance count is a lifetime decision, the access path is a dependency decision. Do not break one to fix the other.
The five things that actually break
First, a realistic "before". A pattern we see often in large codebases looks roughly like this:
final class SessionManager {
static let shared = SessionManager()
private init() {}
var currentUser: User?
var authToken: String?
var recentSearches: [SearchQuery] = []
var draftBooking: BookingDraft?
}
final class CheckoutViewModel {
func confirm() async throws {
guard let user = SessionManager.shared.currentUser,
let draft = SessionManager.shared.draftBooking else { return }
let price: Money
if FeatureFlags.shared.isEnabled(.dynamicPricing) {
price = try await APIClient.shared.quote(for: draft)
} else {
price = draft.listPrice
}
try await APIClient.shared.book(draft, price: price, userID: user.id)
Analytics.shared.track(.bookingConfirmed(draft.id))
SessionManager.shared.draftBooking = nil
}
}
This code works, and it keeps working for years. The problems arrive as the team and the codebase grow.
Hidden dependencies
The signature CheckoutViewModel() says "I need nothing". The body depends on four global objects. When you try to move this class into another module, the compiler teaches you those four dependencies one error at a time. Worse, an APIClient.shared call inside a view model is at least visible; inside a Date extension or a UITableViewCell subclass, nobody sees it. Your dependency graph is clean on the whiteboard and, in reality, full of invisible arrows from every node to the centre.
Global mutable state
The search screen, the detail screen, the deep link handler, the push notification handler and this view model all write to draftBooking. Five writers, zero owners. When the field turns out to be nil, answering "who cleared it" means setting a breakpoint and waiting. These fields are also touched from different threads, and in most projects nothing protects them; the fact that it has not blown up yet is the mercy of timing.
Test isolation and order-dependent tests
SessionManager.shared lives for the whole test process. One test sets currentUser and forgets to clean up; the next test, checking "checkout with no user", finds a user. Tests pass individually, fail together, and a different one fails when the order changes. The first remedy teams reach for is a resetForTesting() method: a method added to production code for tests, which someone forgets to update with every new field. Swift Testing runs tests in parallel by default, so that remedy does not even work there; two tests reset the same global at the same time.
State that outlives the user session
This is the most expensive category. SessionManager lives as long as the process, while the data inside it belongs to the signed-in user. The logout code looks like this:
func logout() {
SessionManager.shared.currentUser = nil
SessionManager.shared.authToken = nil
// recentSearches? draftBooking? The loyaltyPoints added last month?
}
Logout is correct only as long as someone remembers to add every new field to this list. When they forget, user A signs out, user B signs in and sees A's recent searches. That is a security and privacy defect, and its cause is a single design decision: session-lifetime data sits in an app-lifetime object.
Initialization order
In Swift, a static let is initialized on first access, in a thread-safe way (SE-0412 states this explicitly). A Kotlin object is likewise initialized on first access. That sounds safe, but the consequence is that the order is decided by whoever touches it first. If FeatureFlags.shared reads SessionManager.shared.currentUser while initializing, and one day the app is launched in the background by a push notification and touches Analytics.shared first, the initialization chain runs in an order you never tested. What these bugs have in common is that they appear only on one specific launch path.
None of the five problems comes from the sentence "there is one of these". All of them come from access being hidden, state having no owner, and the lifetime being wrong.
What should stay a single instance
Some things are process-wide by nature, and duplicating them does harm.
The HTTP client is first on the list. OkHttp's own documentation is explicit: create a single OkHttpClient and reuse it for all calls, because each client holds its own connection pool and thread pools. Where a different timeout is needed, you derive a client with newBuilder() that shares the same pools instead of creating a new one. The same reasoning applies to URLSession on iOS: the session owns the connections and the cache. Do know the limits Apple documents for URLSession.shared, though: it has no delegate and no customizable configuration, its ability to perform authentication is limited, and it cannot do background transfers. Apple's own advice is to create your own session if you do anything with caches, cookies, authentication or custom protocols. So in a serious app "one URLSession" is the right call, and that one session being URLSession.shared usually is the wrong one.
The logger is the second example. It has no state, or append-only state, its behaviour does not depend on who calls it, and nobody minds it running for real in tests. Passing a logger through every initializer is the dogma's most pointless product. Our preference is to leave the logger globally reachable; the exception is the rare case where you want to assert on log output in a test.
The analytics pipeline, the crash reporter, the disk cache and the database connection belong to the same family: one instance is correct. Unlike the logger, we do not leave access open for these, because we want to swap in a fake in tests and we want the dependency graph to show which module depends on analytics.
The question that separates the cases: does this object's behaviour or content change depending on who is signed in, or on which flow the user is in? If it does not, an app-lifetime single instance is fine. If it does, the lifetime was chosen wrongly.
The practical middle: create once, hand it over
The arrangement we recommend is simple: single instances are created once at the composition root and handed to whoever needs them through the initializer. The object is still unique; what guarantees it now is that only one place creates it, where before it was the static let.
protocol BookingAPI: Sendable {
func quote(for draft: BookingDraft) async throws -> Money
func book(_ draft: BookingDraft, price: Money) async throws
}
@MainActor
final class CheckoutViewModel {
private let drafts: BookingDraftStore
private let api: any BookingAPI
private let flags: any FeatureFlagReading
private let analytics: any AnalyticsTracking
init(drafts: BookingDraftStore,
api: any BookingAPI,
flags: any FeatureFlagReading,
analytics: any AnalyticsTracking) {
self.drafts = drafts
self.api = api
self.flags = flags
self.analytics = analytics
}
func confirm() async throws {
guard let draft = drafts.current else { return }
let price = if flags.isEnabled(.dynamicPricing) {
try await api.quote(for: draft)
} else {
draft.listPrice
}
try await api.book(draft, price: price)
analytics.track(.bookingConfirmed(draft.id))
drafts.current = nil
}
}
Notice that the user parameter has disappeared. BookingAPI is created as an object that belongs to the session, so it already knows on whose behalf it speaks. We will come back to that.
Default parameters as a migration step
In an existing project you cannot change four hundred call sites in a day. This is where a default parameter earns its place:
final class SearchViewModel {
private let session: SessionManager
private let api: APIClient
init(session: SessionManager = .shared, api: APIClient = .shared) {
self.session = session
self.api = api
}
}
No call site changes, the .shared accesses vanish from the class body, and a test can now pass its own instance. It is a step you can take one class per pull request, which is exactly why it is valuable.
Know its limits too. On the production side the dependency is still hidden: whoever writes SearchViewModel() sees nothing. The default value requires importing the concrete type, so it does not help you draw a module boundary. A test author who forgets the argument silently gets the real singleton. Swift 6 adds one more constraint: under SE-0411 a default value expression must be evaluable in the function's own isolation, so if shared is @MainActor and the init is not, the code does not compile. Most importantly, it does nothing about lifetime. We treat default parameters as scaffolding: the day we add one, the step that removes it goes into the plan.
iOS: what Swift 6 does to static let shared
In the Swift 6 language mode (or in Swift 5 mode with complete concurrency checking) the SE-0412 rule applies: every global and static variable must either be isolated to a global actor, or be both immutable and of a Sendable type. Write a global var and the compiler's message summarizes the problem better than we could:
error: global variable 'supportedStyleCount' is not concurrency-safe
because it is non-isolated global shared mutable state
In static let shared = SessionManager() the variable itself is constant, but SessionManager is a non-Sendable class carrying var fields, so the same rule rejects it as well. The compiler now reports as an error what reviewers have been saying for years: this object is shared, unprotected, mutable state.
The honest ways out depend on who owns the state:
import Synchronization
// 1) Configuration that never changes: a Sendable value type can stay a static let.
struct APIEnvironment: Sendable {
let baseURL: URL
let requestTimeout: TimeInterval
static let production = APIEnvironment(
baseURL: URL(string: "https://api.example.com")!,
requestTimeout: 30
)
}
// 2) Mutable state reached from anywhere: an actor.
actor TokenStore {
private var token: AuthToken?
init(initial: AuthToken? = nil) { token = initial }
func current() -> AuthToken? { token }
func replace(with newToken: AuthToken?) { token = newToken }
}
// 3) State only the UI reads and writes: the main actor.
@MainActor
final class BookingDraftStore {
var current: BookingDraft?
}
// 4) Synchronous access required, short critical section: Mutex (iOS 18 and later).
final class FeatureFlagCache: Sendable {
private let values = Mutex<[String: Bool]>([:])
func isEnabled(_ key: String) -> Bool {
values.withLock { $0[key] ?? false }
}
func replaceAll(_ newValues: [String: Bool]) {
values.withLock { $0 = newValues }
}
}
Then there are the dishonest ways out. nonisolated(unsafe) and @unchecked Sendable tell the compiler "I have handled it". The migration guide is direct about nonisolated(unsafe): use it only when you really guard every access with an external mechanism such as a lock or a dispatch queue. A pull request that silences a hundred errors in one evening with those two keywords has switched off the warnings and fixed nothing.
Putting @MainActor on SessionManager is another tempting shortcut. It compiles, and the data race really is gone. The other four of the five problems stay exactly where they were, and the networking layer now has to hop to the main actor to read a token. The default isolation setting that arrived with Swift 6.2 (SE-0466: -default-isolation MainActor on the compiler, defaultIsolation(MainActor.self) in SwiftPM) does this automatically for a whole module: unannotated global and static variables are treated as main-actor isolated. Check your build settings to see whether it is on in your project; if it is, static let shared compiles without complaint, and the question the compiler was asking you, "whose state is this", has been answered silently. Whether the answer is right is still yours to check.
Separating session-scoped from app-scoped objects
The durable fix for logout bugs is to gather everything that belongs to the session under one object that is born and dies with the session:
@MainActor
final class AppDependencies {
let environment: APIEnvironment
let urlSession: URLSession
let analytics: any AnalyticsTracking
let flags: FeatureFlagCache
init(environment: APIEnvironment = .production) {
self.environment = environment
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = environment.requestTimeout
self.urlSession = URLSession(configuration: configuration)
self.analytics = AnalyticsPipeline()
self.flags = FeatureFlagCache()
}
func makeUserSession(user: User, token: AuthToken) -> UserSession {
UserSession(user: user, token: token, app: self)
}
}
@MainActor
final class UserSession {
let user: User
let api: any BookingAPI
let drafts = BookingDraftStore()
let recentSearches = RecentSearchStore()
private let app: AppDependencies
init(user: User, token: AuthToken, app: AppDependencies) {
self.user = user
self.app = app
self.api = AuthenticatedBookingAPI(
urlSession: app.urlSession,
environment: app.environment,
tokens: TokenStore(initial: token)
)
}
func makeCheckoutViewModel() -> CheckoutViewModel {
CheckoutViewModel(drafts: drafts, api: api,
flags: app.flags, analytics: app.analytics)
}
}
@MainActor
final class AppRoot {
let app = AppDependencies()
private(set) var session: UserSession?
func didLogin(user: User, token: AuthToken) {
session = app.makeUserSession(user: user, token: token)
}
func logout() {
session = nil // everything that belongs to the session goes with this line
}
}
logout() no longer keeps a list of fields. When someone adds loyaltyPoints to UserSession next month, logout needs no update, because there is nothing left to forget. Opening the checkout screen while signed out also gets harder at compile time: the only way to produce a CheckoutViewModel is to hold a UserSession.
Android: object, Hilt scopes, process death and Context
On Android the same picture is drawn with a Kotlin object:
object SessionManager {
var currentUser: User? = null
var authToken: String? = null
var draftBooking: BookingDraft? = null
lateinit var context: Context // assigned in MainActivity.onCreate
fun init(context: Context) { this.context = context }
}
class CheckoutViewModel : ViewModel() {
fun confirm() = viewModelScope.launch {
val user = SessionManager.currentUser ?: return@launch
val draft = SessionManager.draftBooking ?: return@launch
ApiClient.instance.book(draft, user.id)
Analytics.track(BookingConfirmed(draft.id))
SessionManager.draftBooking = null
}
}
All five problems from the iOS section are here too. Android adds two more.
A singleton is not persistence
The user fills in a booking draft across three screens, then switches to the banking app at the payment step to read an SMS code. The system kills your process because it needs the memory. When the user returns, Android restores the activity stack and the saved instance state, so the user lands back on the checkout screen. In the new process SessionManager has been created from scratch; draftBooking is null and the confirm() above returns without a word. The developer almost never sees this at their desk, because the test device is not under memory pressure.
The table in the Android documentation is clear: a ViewModel does not survive system-initiated process death, while saved state and persistent storage do. An object and a @Singleton also live in memory and share the same fate. Our rule: a process-lifetime object may be a cache of the truth stored on disk or in saved state, and may never be the source of that truth. UI state for a flow goes into SavedStateHandle; application data (the draft, the token) goes into DataStore or a database. To test this in your own project, "Don't keep activities" in developer options is not enough; send the app to the background, kill the process with adb shell am kill <package> and come back through the recents screen.
The singleton that holds an Activity
Once an Activity is assigned to that lateinit var context: Context, the activity and its entire view tree stay in memory until the process dies. One more each time the screen rotates. Android lint's StaticFieldLeak check exists for exactly this ("Do not place Android context classes in static fields"). The only Context a process-lifetime object may hold is the application context; Hilt provides the @ApplicationContext qualifier for it. If a singleton needs an activity to show a dialog or navigate, that job does not belong to the singleton.
Hilt: @Singleton is a lifetime decision
With Hilt, the single-instance decision moves to the composition root, meaning the modules:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
@Provides
@Singleton
fun provideBookingApi(client: OkHttpClient): BookingApi =
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
.create(BookingApi::class.java)
}
@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val sessionHolder: SessionHolder,
private val analytics: AnalyticsTracker,
private val savedState: SavedStateHandle,
) : ViewModel()
OkHttpClient is still unique, but the only way to reach it now is to ask for it in a constructor. Bindings in Hilt are unscoped by default: a new instance is created for every request. The documentation says scoping can be costly because the object stays in memory until the component is destroyed, and recommends a scope in three situations: internal state that requires the same instance to be shared, a need for synchronization, or objects you have measured to be expensive to create. A @Singleton on a stateless mapper or use case is a label meaning "important class"; remove it. Likewise, for state that belongs to a flow, @ViewModelScoped or @ActivityRetainedScoped is usually a more accurate lifetime than @Singleton. We collected the other common mistakes on this side in DI anti-patterns on Android.
There is one more difference in tests: Hilt builds its own set of components for each test, so @Singleton bindings do not carry over between tests. A Kotlin object lives as long as the test JVM does and carries its state from one test class into the next.
A session scope on Android
Hilt's standard components do not include "signed-in user". You can define a custom component with @DefineComponent, but the Dagger documentation is cautious about it: each new component adds cognitive overhead, complicates the graph and works against standardization. Our first choice is plainer: build the session as an ordinary object graph and put it behind a single app-lifetime holder.
class UserSession(
val user: User,
val bookingRepository: BookingRepository,
val draftStore: DraftStore,
private val scope: CoroutineScope,
) {
fun close() = scope.cancel()
}
@Singleton
class SessionHolder @Inject constructor(
private val factory: UserSessionFactory,
) {
private val _session = MutableStateFlow<UserSession?>(null)
val session: StateFlow<UserSession?> = _session.asStateFlow()
fun start(user: User, token: AuthToken) {
val next = factory.create(user, token)
_session.getAndUpdate { next }?.close()
}
fun end() {
_session.getAndUpdate { null }?.close()
}
}
SessionHolder is a single instance and should stay one. The UserSession inside it is born at login, dies at logout and takes its own coroutine scope with it. After process death SessionHolder starts empty; rebuilding the session at launch from the token in persistent storage is your job, and that follows directly from the cache rule above. Reconsider the custom component once the session graph reaches dozens of classes and wiring it by hand becomes a burden.
Migrating without a big-bang rewrite
The sequence below works the same way on both platforms. Each step can be merged on its own.
- Take inventory. On iOS count
static let sharedandstatic var shareddeclarations and.sharedaccesses; on Android countobjectdeclarations,instancefields inside acompanion object, and@Singletonusages. A plaingrepis enough. Write the number down; it is how you will measure progress. - Classify each one: stateless, app-lifetime state, session-lifetime state, flow-lifetime state, or holding a UI object. The table at the end of this article is for this step.
- Stop new access. Ban new
.sharedusage in the business logic and UI layers with a lint rule or a review rule, and put the existing ones in a baseline. The cleanup never finishes while the bleeding continues. We describe ways to automate the rule in enforcing architecture rules. - Start at the leaves. Pick the view models that change most often or that you most want to test, and move their dependencies into the initializer with default parameters. Call sites stay as they are.
- Push creation upwards. The place that builds a screen (a coordinator, a navigation graph, a factory) starts passing dependencies explicitly. Once every call site of a feature is explicit, delete the defaults in those classes.
- Carve out the session. Create
UserSessionand move the fields ofSessionManagerinto it one at a time. During the transitionSessionManager.shared.draftBookingcan remain as a deprecated computed property that forwards to the active session. Delete the field when its last caller is gone. - Close
shared. Delete, or lower the access level of, everystatic let sharedthat is no longer used outside the composition root. On Android, turn theobjectinto a@Singletonclass.
You can tell it worked from the following: when you run the tests in random order and in parallel (the random order option in an Xcode test plan, Swift Testing's default parallelism, maxParallelForks in Gradle) the result does not change. The inventory number drops every sprint. The test you wrote for "A signs out, B signs in, nothing of A's is visible" passes without a field list inside logout(). On Android, the user carries on where they left off after you kill the process and return.
Where to stop
Driving the grep count to zero should not be the goal. Effort spent injecting the logger, immutable configuration such as APIEnvironment.production, or a thin wrapper around the system's own singletons does not pay back. In a single-target app with three screens, building a composition root is usually a premature investment; there it is enough to apply the session split and, on Android, the process death rule. The boundary: if a singleton is stateless, running the real one in tests is not a problem, and it does not punch through module boundaries, leave it alone.
Decision table
| What | Symptom | Verdict |
|---|---|---|
| Logger | Stateless, behaviour does not depend on the caller | Keep single instance, global access is acceptable |
URLSession / OkHttpClient |
Owns connection and thread pools | Keep single instance, create at the composition root and inject it |
| Analytics, crash reporting, database | Process-wide resource, tests need a fake | Keep single instance, inject it behind an interface |
| Feature flag cache | Mutable state that is independent of the user | Keep single instance with protected concurrency (actor, Mutex), inject it |
| User, token, per-user caches | Anything that must be cleared at logout | Scope it to the session (UserSession) |
| Cart, booking draft, form state | Meaningful for the length of one flow | Scope it to the session or the flow, and on Android persist it as well |
AppState.shared, DataManager, a pile of unrelated fields |
Five writers, zero owners | Remove it: hand the fields to their owners |
| A singleton holding an Activity, view controller or view | Memory leak, wrong lifetime | Remove it |
@Singleton / shared on a stateless mapper, formatter or use case |
No state to share | Remove the singleton-ness, leave it unscoped |
Checklist
- The
shared,objectand@Singletonusages in the project have been counted and the number written down. - Each one is tagged with a verdict from the table.
- New global access in the business logic and UI layers is stopped by a lint or review rule.
- All session state sits under one object; logout consists of letting go of that object.
- iOS: Swift 6 errors were not silenced with
nonisolated(unsafe)or@unchecked Sendable; every use has a note saying what protects it. - iOS: the module's default isolation setting is known and was chosen deliberately.
- Android: no
objector@Singletonholds an Activity, Fragment or View. - Android: after killing the process and returning, the critical flows (payment, forms) carry on where they left off.
- Tests give the same result in random order and in parallel.
- Every temporary
= .sharedadded as a default parameter has its removal step in the plan.
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Sources
- Swift.org · Swift 6 migration guide, Common Compiler Errors
- Swift Evolution · SE-0412 Strict concurrency for global variables
- Swift Evolution · SE-0411 Isolated default value expressions
- Swift Evolution · SE-0466 Control default actor isolation inference
- Apple · URLSession.shared
- Android · Dependency injection with Hilt
- Dagger · Hilt custom components
- Kotlin · Object declarations and expressions
- Android · Save UI states
- OkHttp · OkHttpClient.kt (KDoc)
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.