Skip to content
Commitwise
Menu
Design Patterns

Singleton

Guarantee one shared instance with Swift static let, Kotlin object, Java's holder and enum idioms and TypeScript modules, and learn why singletons become hidden global state, how to make them testable, and where lazy initialization races.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Analytics.shared.track("purchase"). Database.getInstance().query(...). Session.current.user. It’s the first pattern most developers learn and the first one experienced developers argue about. A Singleton makes sure a class has exactly one instance, reachable from anywhere. “Reachable from anywhere” is both why it’s convenient and why it causes some of the hardest bugs in an app.

New to some of these? Open what you need, or skip ahead.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data. TypeScript often skips classes for data and uses plain objects described by an interface.
Basics:What is a design pattern?

A design pattern is a named, reusable solution to a problem that keeps coming up in software design, like “swap one algorithm for another” or “notify many objects of a change”. It’s not a library you install; it’s a way of arranging classes and interfaces that experienced developers recognize.

Knowing the names helps in two ways: you recognize the pattern in code you read (Android, iOS and Spring use many of them), and you can discuss designs quickly in reviews and interviews (“let’s use a Strategy here”).

Basics:What does static mean?

A static property or method (Swift static, Java static, Kotlin companion object or object) belongs to the type itself, not to any one instance. There’s exactly one copy of it for the whole app, and you reach it through the type’s name: Analytics.shared, Math.max(…). That single, app-wide copy is what a singleton builds on.

Part Not started

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, so Analytics() doesn’t compile outside the class. Kotlin’s object keyword 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 imported analytics.

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.

Spot the bugThe next user sees the previous cart

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();

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);

Initialization guarantees per language

HowLazy?Thread-safe creation?
Swift static let shared = X()Global/static lets use swift_onceYes, on first accessYes
Kotlin object XStatic initializer of a generated classOn first access to the classYes (JVM class init lock)
Java static final INSTANCEClass initializationWhen the class is first usedYes
Java holder idiomprivate static class Holder { static final X I = new X(); }On first getInstance()Yes
Java enum X { INSTANCE }Enum constantOn first useYes, and serialization/reflection-proof
TypeScript module exportES module evaluated once per realmOn first importN/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; Kotlin object; Java eager static final, holder idiom or enum (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/actor for 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

  1. Why is a singleton's init made private?What does Kotlin's object keyword give you?Why is a singleton's constructor made private?How does a TypeScript module provide one shared instance?

  2. Why is storing the current user's cart in an app-wide singleton risky?

  3. Which Java singleton idiom is also safe against serialization and reflection creating a second instance?

A singleton has one instance, created once and reachable through the type: Swift static let shared with a private init, Kotlin object, a Java static final field with a private constructor, a TypeScript module-level instance. The instance is shared by the whole app, so it must not hold per-user or per-screen data.

Part Not started

The problem with global access

If every room in a house is wired directly to the same light switch, you can’t test a room’s lamp without turning on the whole house, and you can’t replace one lamp with a different model without rewiring. That’s what happens when classes reach out to Something.shared themselves: they’re wired to that exact instance, invisibly.

Global access turns a dependency into an invisible one: it doesn’t appear in the constructor, so readers, reviewers and tests can’t see it, and every use is coupled to a concrete type. State leaks across tests (order-dependent suites), across features and across users. The fix isn’t removing the single instance; it’s moving the decision of which instance to use to the composition root, which is dependency injection.

Before: Hidden dependency on the shared instance

final class CheckoutViewModel {
    func purchase(_ cart: Cart) async {
        // …
        Analytics.shared.track("purchase")             // invisible, untestable
    }
}
class CheckoutViewModel {
    suspend fun purchase(cart: Cart) {
        // …
        Analytics.track("purchase")                    // invisible, untestable
    }
}
class CheckoutViewModel {
    void purchase(Cart cart) {
        // …
        Analytics.getInstance().track("purchase");     // invisible, untestable
    }
}
import { analytics } from "./analytics";

class CheckoutViewModel {
  async purchase(cart: Cart): Promise<void> {
    // …
    analytics.track("purchase");                       // invisible, untestable
  }
}

After: Still one instance, now passed in

protocol AnalyticsTracking { func track(_ name: String) }
extension Analytics: AnalyticsTracking {}

final class CheckoutViewModel {
    private let analytics: any AnalyticsTracking
    init(analytics: any AnalyticsTracking = Analytics.shared) {   // default for app code
        self.analytics = analytics
    }

    func purchase(_ cart: Cart) async {
        // …
        analytics.track("purchase")
    }
}
interface AnalyticsTracking { fun track(name: String) }

object Analytics : AnalyticsTracking { /* … */ override fun track(name: String) {} }

class CheckoutViewModel(
    private val analytics: AnalyticsTracking = Analytics,       // default for app code
) {
    suspend fun purchase(cart: Cart) {
        // …
        analytics.track("purchase")
    }
}
interface AnalyticsTracking { void track(String name); }

class CheckoutViewModel {
    private final AnalyticsTracking analytics;
    CheckoutViewModel(AnalyticsTracking analytics) { this.analytics = analytics; }   // wired by Spring/Hilt or by hand

    void purchase(Cart cart) {
        // …
        analytics.track("purchase");
    }
}
export interface AnalyticsTracking { track(name: string): void }

class CheckoutViewModel {
  constructor(private readonly analytics: AnalyticsTracking = defaultAnalytics) {}   // default for app code

  async purchase(cart: Cart): Promise<void> {
    // …
    this.analytics.track("purchase");
  }
}

The app still uses one shared instance; nothing changed at runtime. But now:

  • The dependency is visible in the constructor: you can see at a glance that checkout talks to analytics.
  • Tests pass a fake that records events, so they don’t share state with other tests or send real analytics.
  • Swapping implementations (a different analytics vendor, a no-op in debug builds) happens in one place.

Practice: from global to injected, step by step

  1. Find the calls: search for .shared, getInstance() or the module import inside classes.
  2. Describe what’s used with an interface or protocol (AnalyticsTracking with track).
  3. Add a constructor parameter of that type, with the shared instance as the default value (Swift, Kotlin, TypeScript) so existing call sites keep compiling.
  4. Replace the calls inside the class with the parameter.
  5. Write a test that passes a fake and checks what was tracked.

Singletons in DI containers

Spring beans, Hilt @Singleton and Koin single give you one instance per container (application) without global access: the container is the only place that knows about it. That’s usually the right answer for “one connection pool”, “one HTTP client”, “one cache”. Scopes make the lifetime explicit: application, activity/screen (@ActivityRetainedScoped), user session (a custom scope created on login), request. “Singleton” then stops being a pattern and becomes a scope.

Deep diveWhy the GoF singleton is called an anti-patternInterview favorite

The criticism isn’t “one instance is bad”. It’s that the classic implementation bundles three decisions into the class itself: how many instances exist, how they’re created, and how everyone finds them. That violates single responsibility and dependency inversion, makes the instance impossible to substitute in tests (without reflection hacks or reset methods), and creates hidden temporal coupling (code that works only if some other code configured the singleton first). DI containers separate those decisions: the class is ordinary, and the container decides lifetime and wiring.

  • Global access hides dependencies, couples to concrete types and shares state across tests and features.
  • Keep one instance, inject it: protocol/interface + constructor parameter (default to the shared instance for incremental migration), or a DI container scope (Spring bean, Hilt @Singleton, Koin single).
  • Scopes make lifetime explicit: application, session, screen, request.

Check this part

  1. A class calls Analytics.shared.track(…) inside a method. What's the main problem?

  2. After injecting the analytics object, how many instances does the app use at runtime?

  3. What do DI containers change about singletons?

Calling X.shared from inside a class hides a dependency, makes tests share state and couples code to one implementation. Keep the single instance, but pass it in through the constructor, so tests can use a fake and the dependency is visible.

Part Not started

When a singleton is fine

Good fitWhy
LoggerStateless (or append-only), used everywhere
Read-only configurationLoaded once, never changes
HTTP client, database connection poolExpensive to create, designed to be shared
Image or response cacheShared on purpose, with a size limit
Poor fitWhy
The current user, cart, form draftsBelong to a session or a screen, not the app
Anything tests need to controlShared state makes tests depend on each other
“Managers” that do everythingA god object that every class reaches into

A good test: if you’d be happy for every screen, every user and every test to share this object, a singleton is fine. If not, it belongs to something with a shorter life.

The legitimate cases share three properties: genuinely one per process, expensive or meaningful to share, and no per-user state. Even then, inject them. The remaining engineering is lifecycle: creation without races, thread-safe internal state, and shutdown (pools and clients need closing, which a static accessor never does).

Production puzzles

Spot the bugSometimes there are two of them

Tap the line with the bug.

final class ImageCache {
    private static var instance: ImageCache?

    static var shared: ImageCache {
        if instance == nil {
            instance = ImageCache()
        }
        return instance!
    }
}
class ImageCache private constructor() {
    companion object {
        private var instance: ImageCache? = null

        fun get(): ImageCache {
            if (instance == null) instance = ImageCache()
            return instance!!
        }
    }
}
public final class ImageCache {
    private static ImageCache instance;
    private ImageCache() {}

    public static ImageCache get() {
        if (instance == null) instance = new ImageCache();
        return instance;
    }
}
let client: DbClient | undefined;

export async function getClient(): Promise<DbClient> {
  if (!client) {
    client = await DbClient.connect(process.env.DB_URL!);
  }
  return client;
}

Exercisemedium

Retire a god singleton

An app has AppManager.shared with 60 properties and methods: the current user, the cart, feature flags, the network client, analytics, cached images and “last opened screen”. 140 files call it directly, and tests fail depending on their order. Plan the migration without a big-bang rewrite.

›

Group the contents by lifetime (app, user session, screen) and by responsibility. Then make dependencies visible one class at a time, keeping the old accessor working during the migration.

SolutionTry it yourself first
  1. Split by lifetime and responsibility: app-wide services (HttpClient, ImageCache, FeatureFlags, Analytics), user-session state (UserSession: user, cart, tokens, created on login), and screen state (move “last opened screen” into navigation state, owned by the screen).
  2. Facade first: keep AppManager.shared, but make its properties delegate to the new objects. Nothing breaks; the new objects can now be injected elsewhere.
  3. Inject at the edges: for each class you touch, add constructor parameters for what it uses (defaulting to the new instances where your language allows), and stop calling AppManager inside it. Start with the classes that have tests, or that cause flaky tests.
  4. Composition root: create the app-wide services in one place (app startup, a DI container) and the session object on login; tests build their own graph with fakes.
  5. Ratchet: a lint rule or an architecture test that forbids new AppManager.shared uses, and a counter of remaining call sites that only goes down.
  6. Delete AppManager when the count reaches zero.

The order matters: shared mutable state (user, cart) is the source of order-dependent tests and data leaks, so it goes first.

  • Fits: one-per-process, shared on purpose, no per-user state (logger, config, pools, clients, caches). Still inject; handle shutdown.
  • Never hand-roll lazy init: check-then-act races create two instances (threads in Swift/Kotlin/Java, await gaps in TypeScript). Use static let, object/by lazy, holder/enum, or cache the promise (??=).
  • God singletons: split by lifetime and responsibility, facade during migration, inject at the edges, ratchet down call sites.

Check this part

  1. Which is a good fit for a singleton?

  2. What's a quick test for "should this be a singleton"?

  3. static var instance with an if instance == nil check is called from two threads. Risk?A companion get() with if (instance == null) instance = … is called from two threads. Risk?if (instance == null) instance = new X() is called from two threads. Risk?if (!client) client = await connect() is called by two requests at startup. Risk?

Singletons fit things there's truly one of and that hold no per-user state: loggers, configuration, connection pools, caches. Create them safely (language idioms, not hand-written lazy checks), make their state thread-safe, and still inject them.

Key takeaways

  • A singleton has exactly one instance, shared by the whole app: static let shared, object, a private constructor, or a module export.
  • Everything stored in it is shared by every screen and user: keep per-user data out.
  • Calling .shared inside classes hides dependencies; pass the instance in so tests can use a fake.
  • Good fits: loggers, configuration, HTTP clients, caches.
  • Separate “one instance” (fine) from “global access” (avoid); let a DI container own scope and wiring.
  • Use language idioms for safe creation; make internal state thread-safe; handle shutdown.
  • Lazy init by hand races, including across await in TypeScript.
  • Migrate god singletons by lifetime and responsibility, with a facade and a ratchet.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.