One instance of a class and a global way to reach it. The first half is sometimes what you need. The second half is the problem.
Your app needs exactly one config: the tax rate, the feature flags, the paths. So somebody makes Config a Singleton, and every class that needs a setting calls Config.instance(). It is quick to write, and it is one of the patterns interviewers most like to push back on. They are not asking whether you know the code. They are asking whether you know what it costs.
This lesson has the code, in four languages, with the creation made safe. Then it has the costs: a hidden dependency, state shared between tests, and a different set of initialisation traps in each language. Then the replacement, which you already know from the dependency injection lesson: create one instance at startup and pass it in. The case studies avoid it in the parking lot and use a registry of named loggers where a Singleton might have been.
A whiteboard in the corridor that anyone can write on: nobody knows who moved the 3 pm meeting. A global Singleton is that whiteboard.
The first version: a Singleton config that everything reads#
This is the form most people write first. Config.instance() creates the object the first time and returns the same one afterwards, and the checkout reads the tax rate from it:
# config_v1.py: a classic lazy Singleton config, read from inside the checkout
class Config:
_instance = None
created = 0
def __init__(self) -> None:
Config.created += 1
self.tax_percent = 18 # "loaded from a file": the default rate
@classmethod
def instance(cls) -> "Config":
if cls._instance is None: # check, then create: not safe if two threads arrive together
cls._instance = cls()
return cls._instance
class Checkout:
def total(self, subtotal: int) -> int: # the signature says nothing about needing a Config
return subtotal + subtotal * Config.instance().tax_percent // 100
def check(name: str, got: int, want: int) -> None:
print(f"{name}: total {got}, expected {want}: {'ok' if got == want else 'FAILED'}")
def test_twelve_percent() -> None:
Config.instance().tax_percent = 12 # the test changes the one shared object
check("test 1, tax 12 percent", Checkout().total(500), 560)
def test_default_tax() -> None:
check("test 2, default tax", Checkout().total(500), 590) # never touches the config itself
test_twelve_percent()
test_default_tax()
print("instances created:", Config.created)// ConfigV1.java: a classic lazy Singleton config, read from inside the checkout
class ConfigV1 {
static final class Config {
private static Config instance;
static int created = 0;
int taxPercent = 18; // "loaded from a file": the default rate
private Config() { created++; }
static Config instance() {
if (instance == null) instance = new Config(); // check, then create: not safe if two threads arrive together
return instance;
}
}
static final class Checkout {
int total(int subtotal) { // the signature says nothing about needing a Config
return subtotal + subtotal * Config.instance().taxPercent / 100;
}
}
static void check(String name, int got, int want) {
System.out.println(name + ": total " + got + ", expected " + want + ": " + (got == want ? "ok" : "FAILED"));
}
static void testTwelvePercent() {
Config.instance().taxPercent = 12; // the test changes the one shared object
check("test 1, tax 12 percent", new Checkout().total(500), 560);
}
static void testDefaultTax() {
check("test 2, default tax", new Checkout().total(500), 590); // never touches the config itself
}
public static void main(String[] args) {
testTwelvePercent();
testDefaultTax();
System.out.println("instances created: " + Config.created);
}
}// config_v1.cpp: a classic lazy Singleton config, read from inside the checkout
#include <iostream>
#include <string>
class Config {
public:
static int created;
int tax_percent = 18; // "loaded from a file": the default rate
static Config& instance() {
if (instance_ == nullptr) instance_ = new Config(); // check, then create: not safe if two threads arrive together
return *instance_;
}
private:
Config() { ++created; }
static Config* instance_; // never deleted: it lives until the process ends
};
int Config::created = 0;
Config* Config::instance_ = nullptr;
struct Checkout {
int total(int subtotal) const { // the signature says nothing about needing a Config
return subtotal + subtotal * Config::instance().tax_percent / 100;
}
};
void check(const std::string& name, int got, int want) {
std::cout << name << ": total " << got << ", expected " << want << ": " << (got == want ? "ok" : "FAILED") << "\n";
}
void test_twelve_percent() {
Config::instance().tax_percent = 12; // the test changes the one shared object
check("test 1, tax 12 percent", Checkout().total(500), 560);
}
void test_default_tax() {
check("test 2, default tax", Checkout().total(500), 590); // never touches the config itself
}
int main() {
test_twelve_percent();
test_default_tax();
std::cout << "instances created: " << Config::created << "\n";
}// configV1.js: a classic lazy Singleton config, read from inside the checkout
class Config {
static #instance = null;
static created = 0;
constructor() {
Config.created += 1;
this.taxPercent = 18; // "loaded from a file": the default rate
}
static instance() {
if (Config.#instance === null) Config.#instance = new Config(); // check, then create: fine in synchronous code, see the async case below
return Config.#instance;
}
}
class Checkout {
total(subtotal) { // the signature says nothing about needing a Config
return subtotal + Math.floor((subtotal * Config.instance().taxPercent) / 100);
}
}
function check(name, got, want) {
console.log(`${name}: total ${got}, expected ${want}: ${got === want ? "ok" : "FAILED"}`);
}
function testTwelvePercent() {
Config.instance().taxPercent = 12; // the test changes the one shared object
check("test 1, tax 12 percent", new Checkout().total(500), 560);
}
function testDefaultTax() {
check("test 2, default tax", new Checkout().total(500), 590); // never touches the config itself
}
testTwelvePercent();
testDefaultTax();
console.log("instances created:", Config.created);test 1, tax 12 percent: total 560, expected 560: ok
test 2, default tax: total 560, expected 590: FAILED
instances created: 1The numbers, so you can check them: 500 rupees at 12 percent tax is 500 plus 60, which is 560; at the default 18 percent it is 500 plus 90, which is 590. Test 1 changed the rate to 12, and that was fine for test 1. Test 2 never touched the config and expected the default, and it failed, because the one Config object outlived test 1. Run test 2 by itself and it passes. The result depends on which tests ran before it.
- A hidden dependency.
Checkout().total(500)looks like it needs nothing. It reaches out to a global, and the only way to learn that is to read the body. - State leaks between tests. One shared, mutable object means one test's setup is the next test's surprise, and the failure moves when you reorder or run tests in parallel.
- No easy substitute. To test with a different rate you must change the shared object (and remember to change it back), or add a
reset()method to production code for the tests' benefit. - "Exactly one" is rigid. The day you need one config per customer, or per test, every
Config.instance()call site is wrong at once.
Why did test 2 fail?
Test 2 expects 590 and never touches the config. Run alone, it passes; after test 1, it fails. What does that tell you about the design, and where would the fix go?
One strong answer
The two tests share hidden state. Config.instance() is a global, so test 1's change to the tax rate is still there when test 2 runs. The checkout's signature does not show the dependency, so nothing hints at the leak. The fix is not a better test order or a reset(). It is to stop making the checkout reach for a global: give it the config it needs, so each test can build its own.
The pattern#
The Design Patterns book (1994, Gamma, Helm, Johnson and Vlissides) states the intent of Singleton as: "Ensure a class only has one instance, and provide a global point of access to it."
That is two separate ideas in one sentence, and it is worth pulling them apart:
| Idea | What it gives you | What it costs |
|---|---|---|
| Exactly one instance | One shared config, one registry, one connection pool | Little: you can get this by creating one object |
| A global point of access | Anyone can reach it without being handed it | Everything in this lesson: hidden dependencies, shared state, hard tests |
| Role | What it does in the code |
|---|---|
| Singleton class | Hides its constructor, holds the one instance in a static field, and offers a static accessor |
| Client | Any class that calls the accessor: Config.instance() |
%% title: Singleton: a class that owns its only instance
classDiagram
class Config {
-instance Config$
-Config()
+instance() Config$
+tax_percent int
}
class Checkout
class ReportJob
Checkout ..> Config : calls instance()
ReportJob ..> Config : calls instance()Read the diagram. The dollar sign marks a static member. Config owns the one instance (a private static field) and hides its constructor (the minus sign), so the only way to get an object is the static accessor. Every client points a dashed depends-on arrow at it, and the arrow is the problem: Checkout and ReportJob are tied to the concrete Config class, and nothing in their constructors says so.
Singleton is two ideas in one sentence: exactly one instance, which is cheap, and a global point of access, which is where the costs live.
The code: one instance, created safely#
The first version above creates the instance lazily, with a check and then a create. If two callers arrive together while the config is loading, both can see "no instance yet" and both create one: two configs, and whichever the callers stored last is the "one". Each language has a different way to avoid that, and the programs below do it with eight callers at once and a deliberately slow load, so the race window is wide:
# singleton_safe.py: lazy creation that stays at one instance when 8 threads ask at once
import threading
import time
class Config:
created = 0
_instance = None
_lock = threading.Lock()
def __init__(self) -> None:
time.sleep(0.01) # a slow load: the window in which a careless version creates a second one
Config.created += 1
@classmethod
def instance(cls) -> "Config":
if cls._instance is None: # fast path: no lock once it exists
with cls._lock:
if cls._instance is None: # check again: another thread may have won while we waited
cls._instance = cls()
return cls._instance
seen = []
def ask() -> None:
seen.append(Config.instance())
threads = [threading.Thread(target=ask) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
print("8 callers got the same object:", all(s is seen[0] for s in seen))
print("instances created:", Config.created)// SingletonSafe.java: lazy creation that stays at one instance when 8 threads ask at once
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
class SingletonSafe {
static final class Config {
static final AtomicInteger created = new AtomicInteger();
private Config() {
try {
Thread.sleep(10); // a slow load: the window in which a careless version creates a second one
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
created.incrementAndGet();
}
private static final class Holder { // loaded, and so initialised, once and only on first use: the JVM guarantees it
static final Config INSTANCE = new Config();
}
static Config instance() { return Holder.INSTANCE; }
}
public static void main(String[] args) throws InterruptedException {
List<Config> seen = Collections.synchronizedList(new ArrayList<>());
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 8; i++) {
Thread t = new Thread(() -> seen.add(Config.instance()));
threads.add(t);
t.start();
}
for (Thread t : threads) t.join();
boolean same = seen.stream().allMatch(c -> c == seen.get(0));
System.out.println("8 callers got the same object: " + (same ? "True" : "False"));
System.out.println("instances created: " + Config.created.get());
}
}// singleton_safe.cpp: lazy creation that stays at one instance when 8 threads ask at once
// (on Linux, link with -pthread)
#include <array>
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
class Config {
public:
static std::atomic<int> created;
static Config& instance() {
static Config config; // C++11: initialised exactly once, even if threads arrive together
return config;
}
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
private:
Config() {
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // a slow load: the window a careless version would lose
++created;
}
};
std::atomic<int> Config::created{0};
int main() {
std::array<const Config*, 8> seen{};
std::vector<std::thread> threads;
for (std::size_t i = 0; i < seen.size(); ++i) threads.emplace_back([&seen, i] { seen[i] = &Config::instance(); });
for (auto& t : threads) t.join();
bool same = true;
for (const Config* c : seen) same = same && c == seen[0];
std::cout << "8 callers got the same object: " << (same ? "True" : "False") << "\n";
std::cout << "instances created: " << Config::created.load() << "\n";
}// singletonSafe.js: lazy creation that stays at one instance when 8 callers ask at once (async loading)
class Config {
static created = 0;
static #pending = null;
static instance() { // returns a Promise; the first caller starts the load, everyone else waits on the same one
if (Config.#pending === null) Config.#pending = Config.#load();
return Config.#pending;
}
static async #load() {
await new Promise((resolve) => setTimeout(resolve, 10)); // a slow load: the window a careless version would lose
Config.created += 1;
return new Config();
}
}
async function main() {
const seen = await Promise.all(Array.from({ length: 8 }, () => Config.instance()));
console.log("8 callers got the same object:", seen.every((c) => c === seen[0]) ? "True" : "False");
console.log("instances created:", Config.created);
}
main();8 callers got the same object: True
instances created: 1The numbers: eight callers, one slow load, one creation. The first caller starts the load, and the others wait for it or reuse its result. What each language brings:
| Language | The idiomatic safe form | What to know |
|---|---|---|
| Python | A module-level object (config = Config() in config.py, then from config import config), or a lock with a second check, as here |
The import system runs a module once per process and holds a lock while it does, so a module-level object is safe, unless one file is loaded under two names (a script run as __main__ and also imported). A plain if _instance is None is not safe under threads: the interpreter lock (the GIL) can switch threads between the check and the set, and the free-threaded builds since Python 3.13 have no GIL at all. If you override __new__ to return the same object, __init__ still runs on every Config() call and resets the state. functools.lru_cache on a factory can call the function more than once when first calls overlap, as its own documentation says |
| Java | The holder idiom (as here), an enum with one constant, or an eager static final field |
The JVM runs a class's initialisation once, under a lock, so the holder class is created on first use, safely. Double-checked locking is correct only if the field is volatile. An enum singleton also survives serialisation and reflection, which a plain class needs extra code (readResolve, a constructor guard) to match |
| C++ | A function-local static (static Config c; inside instance()), the "Meyers singleton" |
Since C++11, a local static is initialised exactly once even when threads race, and because it is built on first use it also avoids the static initialisation order problem (a global in another file used before it is constructed). Mind the other end: statics are destroyed in reverse order at exit, so a destructor that calls the singleton after it was destroyed is undefined behaviour |
| JavaScript | A module-level export | Synchronous code in one thread cannot race. Async creation can: if (!instance) instance = await load() lets two callers both start a load. Cache the promise, as here. A worker thread gets its own copy of every module, and two copies of a package in node_modules, or one package loaded through both require and import, give you two instances |
Java has two more safe forms worth knowing by sight: the enum with one constant (Joshua Bloch's recommendation in Effective Java) and double-checked locking, which is correct only because the field is volatile:
// JavaForms.java: Java's two other safe forms, side by side
class JavaForms {
enum AppConfig { // an enum with one constant: created once by the JVM, and safe from serialisation and reflection copies
INSTANCE;
final int taxPercent = 18;
}
static final class LazyConfig { // double-checked locking: correct only because the field is volatile
private static volatile LazyConfig instance;
final int taxPercent = 18;
private LazyConfig() {}
static LazyConfig instance() {
LazyConfig local = instance; // one volatile read on the fast path
if (local == null) {
synchronized (LazyConfig.class) {
local = instance;
if (local == null) instance = local = new LazyConfig(); // check again: another thread may have won
}
}
return local;
}
}
public static void main(String[] args) {
System.out.println("enum: " + (AppConfig.INSTANCE == AppConfig.valueOf("INSTANCE")) + ", tax " + AppConfig.INSTANCE.taxPercent);
System.out.println("double-checked: " + (LazyConfig.instance() == LazyConfig.instance()) + ", tax " + LazyConfig.instance().taxPercent);
}
}enum: true, tax 18
double-checked: true, tax 18A check followed by a create is two steps. Two callers can both see no instance, and both create one, unless the pair is made one step.
Why a global hurts#
Making creation safe fixes the concurrency trap. It does not touch the deeper problem, which is the second half of the intent:
Singleton: reach for it
- The dependency is hidden: it is not in any signature
- State is shared by every caller and every test
- A fake means changing the shared object, or a test-only
reset() - "One" is built into the class, so "one per customer" is a rewrite
Injected: be handed it
- The dependency is in the constructor, so a reader sees it
- Each test builds its own, so tests cannot affect each other
- A fake is just another object passed in
- "One" is a fact about how you wired the app, and it can change
Three more costs show up in real code:
- Lifetime and order. A Singleton's creation happens whenever it is first touched, so start-up order starts to matter. In C++ a static can be used after it is destroyed. In Java a failure inside the class initialiser throws
ExceptionInInitializerErroronce andNoClassDefFoundErroron every later use. In Python, work done at import time slows every import and runs again in every test that imports the module. - It does two jobs. The class does its real work and also controls its own creation and lifetime. That breaks the single responsibility principle, and the second job is the one that gets in the way of testing.
- It spreads. Once one thing is a Singleton, anything that uses it is welded to it, and the next developer reaches for
.instance()because it is the easy path.
Instead: create one, and pass it#
You almost never need the class to enforce that there is one. You need one object, made at start-up. The fix is the one from the injection lesson: build it in the composition root and hand it to the classes that need it. Here is the same config and a logger, with no Singleton class at all:
# config_di.py: one Config and one Logger made at startup and passed in; no global, no Singleton class
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
tax_percent: int = 18
class Logger:
def __init__(self) -> None:
self.lines = []
def info(self, message: str) -> None:
self.lines.append(message)
class Checkout: # what it needs is in its signature
def __init__(self, config: Config, logger: Logger) -> None:
self.config, self.logger = config, logger
def total(self, subtotal: int) -> int:
total = subtotal + subtotal * self.config.tax_percent // 100
self.logger.info(f"total {total}")
return total
class ReportJob:
def __init__(self, config: Config) -> None:
self.config = config
def check(name: str, got: int, want: int) -> None:
print(f"{name}: total {got}, expected {want}: {'ok' if got == want else 'FAILED'}")
# each test builds exactly what it needs, so tests cannot affect each other
logger = Logger()
check("test 1, tax 12 percent", Checkout(Config(tax_percent=12), logger).total(500), 560)
check("test 2, default tax", Checkout(Config(), Logger()).total(500), 590)
print("log lines kept by test 1's logger:", len(logger.lines))
# composition root: one Config for the whole app, shared by passing it to whoever needs it
config, app_logger = Config(), Logger()
checkout, report = Checkout(config, app_logger), ReportJob(config)
print("same Config in both services:", checkout.config is report.config)// ConfigDi.java: one Config and one Logger made at startup and passed in; no global, no Singleton class
import java.util.ArrayList;
import java.util.List;
class ConfigDi {
record Config(int taxPercent) {
Config() { this(18); }
}
static final class Logger {
final List<String> lines = new ArrayList<>();
void info(String message) { lines.add(message); }
}
static final class Checkout { // what it needs is in its signature
final Config config;
private final Logger logger;
Checkout(Config config, Logger logger) {
this.config = config;
this.logger = logger;
}
int total(int subtotal) {
int total = subtotal + subtotal * config.taxPercent() / 100;
logger.info("total " + total);
return total;
}
}
static final class ReportJob {
final Config config;
ReportJob(Config config) { this.config = config; }
}
static void check(String name, int got, int want) {
System.out.println(name + ": total " + got + ", expected " + want + ": " + (got == want ? "ok" : "FAILED"));
}
public static void main(String[] args) {
// each test builds exactly what it needs, so tests cannot affect each other
Logger logger = new Logger();
check("test 1, tax 12 percent", new Checkout(new Config(12), logger).total(500), 560);
check("test 2, default tax", new Checkout(new Config(), new Logger()).total(500), 590);
System.out.println("log lines kept by test 1's logger: " + logger.lines.size());
// composition root: one Config for the whole app, shared by passing it to whoever needs it
Config config = new Config();
Checkout checkout = new Checkout(config, new Logger());
ReportJob report = new ReportJob(config);
System.out.println("same Config in both services: " + (checkout.config == report.config ? "True" : "False"));
}
}// config_di.cpp: one Config and one Logger made at startup and passed in; no global, no Singleton class
#include <iostream>
#include <string>
#include <vector>
struct Config {
int tax_percent = 18;
};
class Logger {
public:
std::vector<std::string> lines;
void info(const std::string& message) { lines.push_back(message); }
};
class Checkout { // what it needs is in its signature
const Config& config_;
Logger& logger_;
public:
Checkout(const Config& config, Logger& logger) : config_(config), logger_(logger) {}
const Config& config() const { return config_; }
int total(int subtotal) {
int total = subtotal + subtotal * config_.tax_percent / 100;
logger_.info("total " + std::to_string(total));
return total;
}
};
class ReportJob {
const Config& config_;
public:
explicit ReportJob(const Config& config) : config_(config) {}
const Config& config() const { return config_; }
};
void check(const std::string& name, int got, int want) {
std::cout << name << ": total " << got << ", expected " << want << ": " << (got == want ? "ok" : "FAILED") << "\n";
}
int main() {
// each test builds exactly what it needs, so tests cannot affect each other
Logger logger;
Config twelve;
twelve.tax_percent = 12;
check("test 1, tax 12 percent", Checkout(twelve, logger).total(500), 560);
Config standard;
Logger other;
check("test 2, default tax", Checkout(standard, other).total(500), 590);
std::cout << "log lines kept by test 1's logger: " << logger.lines.size() << "\n";
// composition root: one Config for the whole app, shared by passing it to whoever needs it
Config config;
Logger app_logger;
Checkout checkout(config, app_logger);
ReportJob report(config);
std::cout << "same Config in both services: " << (&checkout.config() == &report.config() ? "True" : "False") << "\n";
}// configDi.js: one Config and one Logger made at startup and passed in; no global, no Singleton class
class Config {
constructor(taxPercent = 18) {
this.taxPercent = taxPercent;
Object.freeze(this);
}
}
class Logger {
constructor() {
this.lines = [];
}
info(message) {
this.lines.push(message);
}
}
class Checkout { // what it needs is in its signature
constructor(config, logger) {
this.config = config;
this.logger = logger;
}
total(subtotal) {
const total = subtotal + Math.floor((subtotal * this.config.taxPercent) / 100);
this.logger.info(`total ${total}`);
return total;
}
}
class ReportJob {
constructor(config) {
this.config = config;
}
}
function check(name, got, want) {
console.log(`${name}: total ${got}, expected ${want}: ${got === want ? "ok" : "FAILED"}`);
}
// each test builds exactly what it needs, so tests cannot affect each other
const logger = new Logger();
check("test 1, tax 12 percent", new Checkout(new Config(12), logger).total(500), 560);
check("test 2, default tax", new Checkout(new Config(), new Logger()).total(500), 590);
console.log("log lines kept by test 1's logger:", logger.lines.length);
// composition root: one Config for the whole app, shared by passing it to whoever needs it
const config = new Config();
const checkout = new Checkout(config, new Logger());
const report = new ReportJob(config);
console.log("same Config in both services:", checkout.config === report.config ? "True" : "False");test 1, tax 12 percent: total 560, expected 560: ok
test 2, default tax: total 590, expected 590: ok
log lines kept by test 1's logger: 1
same Config in both services: TrueTest 1 builds a config with a 12 percent rate and gets 560; test 2 builds its own default and gets 590, so the leak is gone, and the order no longer matters. Test 1's logger kept its one line, and nothing outside test 1 saw it. At the bottom, the composition root builds one Config for the whole app and hands it to the checkout and the report job: same Config in both services: True. That is the "one instance" you wanted, and no class had to enforce it. The Config is also immutable here (a frozen dataclass, a record, a const reference, a frozen object), which removes the shared-mutable-state half of the problem even for the app's one copy.
%% title: One Config, created at start-up and passed in
classDiagram
class Main {
+build()
}
class Config {
+tax_percent int
}
class Logger {
+info(message)
}
class Checkout
class ReportJob
Main ..> Config : creates once
Main ..> Logger : creates once
Checkout --> Config : holds
Checkout --> Logger : holds
ReportJob --> Config : holdsIf you want one thing shared, there is usually a better tool than a Singleton class:
| You want | Use |
|---|---|
| One config for the app | Create it in main, make it immutable, pass it in |
| A logger per component | A registry keyed by name, such as LogManager.get(name) |
| A stateless helper | Plain functions in a module, or a static class with no state |
| One instance per container, with a framework | The container's singleton scope: one per container, injected |
| A value threaded through many layers | A context object passed down, instead of a global |
| Exactly one across machines | Leader election or a lock in shared storage |
We need exactly one. Isn't that a Singleton?
A teammate says: "Config must exist exactly once, so it should be a Singleton." What is the difference between needing one instance and writing a Singleton?
One strong answer
Needing one is a fact about how you wire the app. A Singleton is a way to make the class enforce it and let anyone reach it. You can have exactly one Config by creating it once in main and passing it in, as the program does. What the Singleton adds on top is the global access, and that is the part that hides the dependency and shares state between tests. If you want a guard against a second instance, a test or a startup check can assert it, without making every class reach for a global.
You need one config, not a class that enforces it. Create it once at start-up and hand it to the checkout and the report job.
Singleton and its look-alikes#
| Idea | What it is | How to tell it from Singleton |
|---|---|---|
| A global variable or module-level object | A name any code can reach | In Python and JavaScript this is the idiomatic singleton and is safe to create. It has the same costs as the class: hidden dependency, shared state |
| A static (utility) class | Functions grouped under a name, with no instance | Fine for stateless helpers such as Math. If it holds mutable state, it is a Singleton without the object |
| A registry (Multiton) | One instance per key | LogManager.get(name) returns the same logger for the same name. There is no "the one", and a test can build its own manager |
| A container's "singleton scope" | One instance per DI container, such as Spring's default bean scope | The class has no private constructor or global accessor, so another container, in another test, makes another one. It is the "one, passed in" form, automated |
| Monostate | Many objects that all share static fields | Looks like a normal class and behaves like a Singleton: shared hidden state, with a friendlier face |
| Factory | Builds objects on request | A factory may hand back the same object each time, but that is a policy it can change, not a feature of the class |
When not to use it#
- "We only need one" is not enough. A config, a service object, a manager, a connection pool: create one at start-up and pass it in. You get the one instance, with a visible dependency and a fake for tests.
- To avoid passing a parameter through many layers. The pain is real, but the answer is a context object passed down, or a framework's injection, not a global. Hidden dependencies do not get cheaper by being convenient.
- A Singleton that holds mutable state (a request counter, a "current user") is shared by every caller and every thread. In a web server, that means requests see each other's data.
- A
reset()just for tests. If the tests need a back door, the class is telling you it should have been passed in. - The honest cases are narrow. A thing that is truly one per process, belongs to the environment rather than your design, and is effectively stateless or immutable: a standard-output handle, Java's
Runtime.getRuntime(), a stateless no-op object. Even then, a module-level object or an instance passed in frommaingives you the same result with less coupling. Global named loggers (a registry, as in many languages' logging libraries) are widely accepted for the same reason: logging is cross-cutting and almost write-only.
We only need one is not a reason for a Singleton. Create one at start-up and pass it in: you keep one instance and lose the global.
Where it shows up in the case studies#
| Case study | What | How |
|---|---|---|
| Parking lot | Singleton, avoided | Its patterns table lists it in italics as avoided: "Many candidates make ParkingLot a singleton. It buys nothing and makes tests share state." The lot is created once at start-up and passed around, and takes its strategies in ParkingLot(floors, assigner, pricing) |
| Pub-sub logger | Registry, not Singleton | LogManager.get(name) returns one logger per name, so get("search") twice is the same object. The LogManager is an ordinary object built in main, so a test can have its own |
| Rate limiter | Safe lazy creation, per key | The per-key registry creates a limiter on first use with double-checked creation: look without the lock, and if it is missing, take the lock, look again, and create. The same idea as the safe program above, one instance per key instead of one overall |
In the interview#
Say it with the cost: "I'd create one Config at start-up and pass it to whoever needs it, rather than make it a Singleton, because a Singleton hides the dependency and shares state between tests. If I had to have one, I would use the language's safe lazy form: a function-local static in C++, the holder idiom in Java, a module-level object in Python or JavaScript."
Then expect the follow-ups:
- "Is Singleton ever the right answer?" For something that is truly one per process, belongs to the environment, and is stateless or immutable, yes, and I would still pass it in. I would say what it costs: hidden dependency and shared state.
- "How do you make a Singleton thread-safe in Java?" The holder idiom, an enum, or an eager
static final: the JVM initialises a class once. Double-checked locking works only with avolatilefield. - "Why does double-checked locking need
volatile?" Without it, one thread can see a non-null reference to an object whose construction has not finished being published to it.volatilegives the ordering that rules that out. - "How do you test code that uses a Singleton?" Awkwardly, which is the point: you must reset or replace shared state around each test. With injection, each test builds its own.
- "Singleton versus a static class?" A static class has no instance and so cannot implement an interface or be passed around; a Singleton is an object, so it can, but it is still a global.
- "How would you get exactly one across several servers?" Not with a pattern. That is leader election or a lock in shared storage.
Common mistakes#
- "We only need one, so Singleton." Needing one object is not a reason to make the class enforce it and expose it globally.
- Lazy creation with no synchronisation (a check, then a create) in a program with threads.
- Double-checked locking without
volatilein Java. - Overriding
__new__in Python and forgetting that__init__runs again on every call, resetting the state. - Async initialisation that is not cached as a promise in JavaScript, so two callers each start a load.
- A Singleton holding per-request state, shared across requests and threads.
- A
reset()method added for tests, or tests that rely on running in a fixed order. - Believing it is one per system. It is one per process: four workers, four singletons.
Singleton
Q1/4 Test 2 never touches the Singleton config, but fails when it runs after test 1 and passes alone. What is the cause?
Answer & explanation
Answer: C
A Singleton outlives every test, so state left by one is seen by the next. The dependency is also hidden from the checkout's signature, so nothing warns you. Injecting a config per test removes the leak.
Q2/4 Eight threads ask for the instance at the same moment, and the first one is slow to create it. What does a plain check-then-create do?
Answer & explanation
Answer: B
A check followed by a create is two steps, and threads can interleave between them. The idiomatic safe forms are a lock with a second check, the holder idiom in Java, a function-local static in C++ and a module-level object or a cached promise in JavaScript.
Q3/4 Which statement about double-checked locking in Java is right?
Answer & explanation
Answer: C
Without volatile, another thread can see the reference before the object is fully visible to it. With volatile the ordering is guaranteed. The holder idiom and an enum avoid the problem, because the JVM initialises a class once.
Q4/4 A team needs exactly one Config in the app. What is the better design?
Answer & explanation
Answer: B
One instance is a fact about how the app is wired. Creating it once in the composition root and passing it in gives you that, with visible dependencies and a separate config per test. A global enforces it at the price of hidden shared state.
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


