One instance for the whole app
A building has one front desk. Everyone who needs a parcel, a key or directions goes to the same desk, and there’s no point in each floor having its own copy: there would be confusion about which one has your parcel.
A singleton is a class with exactly one instance, like that desk. Every part of the app that needs it gets the same object. It’s a natural fit for things there’s genuinely one of: the app’s logger, its configuration, the connection to the local database.
The Singleton pattern combines two things: exactly one instance (enforced by hiding the constructor) and global access (a static accessor). The first is often legitimate (one connection pool, one image cache); the second is what makes it controversial, because global access hides dependencies and couples code to a concrete type. Modern code usually keeps the first and drops the second: one instance, created once, passed in through dependency injection.
final class Analytics {
static let shared = Analytics() // created on first use, once, thread-safe
private init() {} // nobody else can create one
private(set) var events: [String] = []
func track(_ name: String) { events.append(name) }
}
Analytics.shared.track("purchase")object Analytics { // the language creates exactly one, on first use
private val _events = mutableListOf<String>()
val events: List<String> get() = _events
fun track(name: String) { _events += name }
}
Analytics.track("purchase")public final class Analytics {
private static final Analytics INSTANCE = new Analytics(); // created once, when the class loads
private Analytics() {} // nobody else can create one
public static Analytics getInstance() { return INSTANCE; }
private final List<String> events = new CopyOnWriteArrayList<>();
public void track(String name) { events.add(name); }
}
Analytics.getInstance().track("purchase");class Analytics {
private readonly events: string[] = [];
track(name: string): void { this.events.push(name); }
}
export const analytics = new Analytics(); // a module runs once, so every import gets this instance
// elsewhere:
// import { analytics } from "./analytics";
analytics.track("purchase");Each version has the same two ingredients:
- Only one can exist. Swift and Java make the initializer
private, soAnalytics()doesn’t compile outside the class. Kotlin’sobjectkeyword creates the single instance for you. In TypeScript, a module’s code runs once, so the exported constant is shared by every file that imports it. - Everyone can reach it.
Analytics.shared,Analytics,Analytics.getInstance(), or the importedanalytics.
What belongs in a singleton
The one instance lives as long as the app and is shared by every screen and every user session. Anything stored in it is shared too.
Tap the line with the bug.
final class Session {
static let shared = Session()
private init() {}
var token: String?
var cartItems: [Item] = []
func logOut() {
token = nil
}
}object Session {
var token: String? = null
val cartItems = mutableListOf<Item>()
fun logOut() {
token = null
}
}public final class Session {
private static final Session INSTANCE = new Session();
public static Session get() { return INSTANCE; }
private Session() {}
public String token;
public final List<Item> cartItems = new ArrayList<>();
public void logOut() {
token = null;
}
}class Session {
token: string | undefined;
cartItems: Item[] = [];
logOut(): void {
this.token = undefined;
}
}
export const session = new Session();Hint: On a shared family tablet, Ana logs out and Ben logs in. Which of Ana's data is still in the one Session instance?
logOut clears the token but not the cart, and the session object itself lives on. Ben logs in and sees Ana’s cart. Every field added to Session later (recently viewed items, a draft message, a cached profile) has to be remembered in logOut too, and eventually one won’t be.
Two fixes, the second more robust:
- Reset everything in
logOut. - Better: don’t keep per-user data in an app-wide singleton. Keep a
UserSessionobject that is created on login and thrown away on logout; the app-wide part only holds a reference to the current one. Throwing the object away clears everything at once, including fields nobody remembered.
final class AppState {
static let shared = AppState()
private init() {}
private(set) var session: UserSession? // per-user data lives here
func logIn(token: String) { session = UserSession(token: token) }
func logOut() { session = nil } // everything for that user goes away
}object AppState {
var session: UserSession? = null // per-user data lives here
private set
fun logIn(token: String) { session = UserSession(token) }
fun logOut() { session = null } // everything for that user goes away
}public final class AppState {
private static final AppState INSTANCE = new AppState();
public static AppState get() { return INSTANCE; }
private AppState() {}
private UserSession session; // per-user data lives here
public void logIn(String token) { session = new UserSession(token); }
public void logOut() { session = null; } // everything for that user goes away
}class AppState {
session: UserSession | undefined; // per-user data lives here
logIn(token: string): void { this.session = new UserSession(token); }
logOut(): void { this.session = undefined; } // everything for that user goes away
}
export const appState = new AppState();Predict the output
This version forgot to make the initializer/constructor private. What does it print?
final class Analytics {
static let shared = Analytics()
var events: [String] = []
}
Analytics.shared.events.append("open")
let mine = Analytics()
mine.events.append("purchase")
print(Analytics.shared.events.count)class Analytics {
val events = mutableListOf<String>()
companion object { val shared = Analytics() }
}
Analytics.shared.events += "open"
val mine = Analytics()
mine.events += "purchase"
println(Analytics.shared.events.size)public class Analytics {
public static final Analytics SHARED = new Analytics();
public final List<String> events = new ArrayList<>();
}
Analytics.SHARED.events.add("open");
Analytics mine = new Analytics();
mine.events.add("purchase");
System.out.println(Analytics.SHARED.events.size());export class Analytics {
events: string[] = [];
}
export const analytics = new Analytics();
analytics.events.push("open");
const mine = new Analytics();
mine.events.push("purchase");
console.log(analytics.events.length);Analytics() creates a second instance with its own empty list, so the track call on it doesn't reach the shared one. The shared instance has only one event: 1. In a real app, half the screens report to one object and half to another, and the dashboard misses events. Making the initializer private (private init, private constructor) turns that mistake into a compile error. In TypeScript, don't export the class at all, only the instance.
Initialization guarantees per language
| How | Lazy? | Thread-safe creation? | |
|---|---|---|---|
Swift static let shared = X() | Global/static lets use swift_once | Yes, on first access | Yes |
Kotlin object X | Static initializer of a generated class | On first access to the class | Yes (JVM class init lock) |
Java static final INSTANCE | Class initialization | When the class is first used | Yes |
| Java holder idiom | private static class Holder { static final X I = new X(); } | On first getInstance() | Yes |
Java enum X { INSTANCE } | Enum constant | On first use | Yes, and serialization/reflection-proof |
| TypeScript module export | ES module evaluated once per realm | On first import | N/A (single-threaded) |
Thread-safe creation says nothing about thread-safe state: a singleton’s mutable fields are shared by every thread. In Swift 6, static let shared of a non-Sendable class is a compile error (“static property is not concurrency-safe”); the fixes are @MainActor on the class, making it an actor, or a Sendable design with internal locking.
- Singleton = single instance (hidden constructor) + global access (static accessor). Keep the first, prefer injecting over the second.
- Idioms: Swift
static let+private init; Kotlinobject; Java eager static final, holder idiom orenum(serialization-safe); TypeScript module-level instance (don’t export the class). - Creation is thread-safe in all of these; state isn’t. Swift 6 requires
Sendable/@MainActor/actorfor shared statics. - Never put per-user or per-screen state in an app-lifetime object; scope it to a session object that’s replaced on logout.
Check this part
Why is a singleton's init made private?What does Kotlin's
objectkeyword give you?Why is a singleton's constructor made private?How does a TypeScript module provide one shared instance?Why is storing the current user's cart in an app-wide singleton risky?
Which Java singleton idiom is also safe against serialization and reflection creating a second instance?