Design Patterns (GoF and modern) Module 2 – Creational patterns: Factory Method to Singleton
Singleton: one instance and its real costs
Singleton promises one instance and global access. Thread-safe lazy creation in six languages, the race it hides, its cost in tests, and how DI replaces it.
What you will learn
- Implement a safe, lazily initialised single instance in each of the six languages
- Explain why Singleton makes testing and reasoning harder, and when it is still acceptable
- Replace a Singleton with dependency injection of one shared instance
- Recognise module-level singletons, multitons and registries
Before you start
What you’ll learn · Before you start
What you will learn
- Implement a safe, lazily initialised single instance in each of the six languages
- Explain why Singleton makes testing and reasoning harder, and when it is still acceptable
- Replace a Singleton with dependency injection of one shared instance
- Recognise module-level singletons, multitons and registries
Before you start
On this page
Singleton is a creational design pattern that ensures a class has exactly one instance and gives the whole
program a global way to reach it, usually a static get() or instance() method. It makes two promises, and they
are worth separating. One instance is often exactly right: one configuration, one connection pool, one log.
Global access is what causes most of the harm: classes that fetch the instance hide a dependency their
constructors do not show, any code can change the shared state, and tests that touch it start to depend on the order
they run in. Creating the instance lazily, on first use, adds a race of its own. Most modern code keeps the single
instance and drops the global access: create it once at start-up and pass it to the classes that need it.
This lesson reproduces the lazy-creation race in Python, shows the safe single instance of Java, TypeScript, Go, C++ and Rust under 16 threads, finds two ways one “singleton” quietly becomes two, measures the cost in tests, and ends with the cases where a single instance is the right design.
Two promises, and what each costs
The catalogue’s version is a class that hides its constructor and offers a static method returning the one instance, creating it on the first call. Each half of that design has a price:
- Global access turns the instance into a global variable with a nicer name. A class that calls
Settings.get()inside its methods depends onSettings, but nothing in its signature says so, so readers and tests discover it the hard way. - Shared mutable state means a change made by one part of the program, or by one test, is seen everywhere, and undoing it becomes somebody’s job.
- Lazy creation has to be safe when two threads, or two asynchronous tasks, ask at the same moment, or the “one” instance is created several times.
- Lifetime is the whole process: the instance is created at a moment you do not choose and is hard to replace, which is exactly what a test wants to do.
The race in lazy creation
"""Three lazy getters raced by 16 threads that start at the same moment. The settings take 50 ms
to load, as reading a file or asking a server would, which is long enough for the other
threads to arrive before the first load ends."""
import functools
import threading
import time
class Loads:
"""Counts loads safely from many threads."""
def __init__(self):
self.count, self._lock = 0, threading.Lock()
def load(self):
with self._lock:
self.count += 1
time.sleep(0.05)
return {"currency": "INR", "tax_rate_percent": 18}
def race(get, threads=16):
"""Calls get() from `threads` threads released together; returns how many different objects
they received."""
barrier, seen, lock = threading.Barrier(threads), [], threading.Lock()
def worker():
barrier.wait()
value = get()
with lock:
seen.append(value)
workers = [threading.Thread(target=worker) for _ in range(threads)]
for w in workers:
w.start()
for w in workers:
w.join()
return len({id(v) for v in seen})
# 1. Check, then create: every thread that checks before the first load has finished loads
# again.
loads = Loads()
class NaiveSettings:
_instance = None
@classmethod
def get(cls):
if cls._instance is None:
cls._instance = loads.load()
return cls._instance
race(NaiveSettings.get)
print(f"check, then create: loaded more than once: {loads.count > 1}")
# 2. functools.cached_property: since Python 3.12 it takes no lock, so its getter can run once
# per thread.
loads = Loads()
class Config:
@functools.cached_property
def settings(self):
return loads.load()
config = Config()
race(lambda: config.settings)
print(f"cached_property: loaded more than once: {loads.count > 1}")
# 3. The check and the creation inside one lock: one load, and every thread gets the same
# object.
loads = Loads()
class LockedSettings:
_instance = None
_lock = threading.Lock()
@classmethod
def get(cls):
with cls._lock:
if cls._instance is None:
cls._instance = loads.load()
return cls._instance
distinct = race(LockedSettings.get)
print(f"check inside a lock: loaded {loads.count} time; threads got {distinct} object") Output
check, then create: loaded more than once: True cached_property: loaded more than once: True check inside a lock: loaded 1 time; threads got 1 object
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lazy.py
The settings take 50 ms to load, and 16 threads ask for them at the same moment:
- Check, then create. Every thread that runs
if cls._instance is Nonebefore the first load has finished sees nothing and loads again. The “one” instance was created several times, and different threads may keep different copies. functools.cached_propertylooks like a ready-made lazy attribute, but its documentation says that since Python 3.12 it no longer takes a lock, so its getter can run more than once on the same instance when threads race, and the last run wins.- The check inside a lock makes the check and the creation one step: the other 15 threads wait, then find the instance. One load, one object.
The free-threaded build of CPython, which PEP 779 made officially supported in Python 3.14, runs Python threads truly in parallel, so races like these get easier to hit, not harder. The pattern itself does not depend on threads: the TypeScript version below shows the same race with asynchronous callers in a single thread.
Python’s built-in singleton: the module
Python has a single instance built in: the module. The programming FAQ relies on it when it explains how to share settings across modules: there is only one instance of each module, so a change to a module’s attribute is seen by every importer. A module of settings is the idiomatic Python singleton, until the same file gets a second name:
modules/main.py
"""A module is Python's built-in singleton, until the same file is imported under two names."""
import logging
import sys
import app.config
import app.config as again
print("imported twice, one module object:", app.config is again)
# A second entry on sys.path makes the same file importable as "config" as well as "app.config".
sys.path.insert(0, "app")
import config # noqa: E402
print("app.config is config:", app.config is config)
config.settings["currency"] = "USD"
print("app.config still says:", app.config.settings["currency"])
# A registry of named instances: one logger per name, for the whole process.
print("one logger per name:", logging.getLogger("shop.orders") is logging.getLogger("shop.orders")) Output
running config.py as the module 'app.config' imported twice, one module object: True running config.py as the module 'config' app.config is config: False app.config still says: INR one logger per name: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py
app/config.py · modules/app/config.py
"""The application's settings: one module object, so one set of settings, for as long as it has
one name."""
print(f"running config.py as the module {__name__!r}")
settings = {"currency": "INR", "tax_rate_percent": 18} Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Importing app.config twice returns one module object, because Python keeps every imported module in
sys.modules under its name. But the cache is keyed by that name, not by the file. When sys.path also contains the
app folder, as it does when a script inside a package is run directly or a tool adds folders to the path, the same
file is importable as config too. The output shows config.py running twice, two module objects, and a currency
change made through one of them that the other never sees.
The last line shows the friendlier relative of Singleton: a registry of named instances, sometimes called a
multiton. logging.getLogger(name) returns the same logger for the same name for the whole process, so modules can
share a logger without passing it around, and the name makes it explicit which one they share.
One instance in six languages
Each program below creates its instance lazily and lets 16 threads (in TypeScript, 16 asynchronous callers) ask for it at the same moment. The Go program runs under the race detector and the C++ program under ThreadSanitizer, so a data race would be reported in the output.
Java · compiled/Lazy.java
// One lazily created instance in Java 25, raced by 16 threads that start together; the settings
// take 50 ms to load.
// Run: java Lazy.java
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
public class Lazy {
static final AtomicInteger LOADS = new AtomicInteger();
static Map<String, String> load() {
LOADS.incrementAndGet();
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return Map.of("currency", "INR");
}
// 1. Check, then create, with no synchronisation: a data race, and several loads.
static final class Naive {
private static Map<String, String> instance;
static Map<String, String> get() {
if (instance == null) instance = load();
return instance;
}
}
// 2. The holder idiom: Holder is initialised on first use, and the JVM's class
// initialisation lock (JLS 12.4.2) makes every other thread wait until it is done.
static final class Settings {
private static final class Holder {
static final Map<String, String> INSTANCE = load();
}
static Map<String, String> get() {
return Holder.INSTANCE;
}
}
// 3. An enum with one constant: the JLS allows no other instances of an enum class (JLS
// 8.9).
enum Rates {
INSTANCE;
final Map<String, String> values = load();
}
/** Calls get() from 16 threads released together; returns how many objects they received. */
static int race(Supplier<Object> get) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
Set<Object> byIdentity = Collections.newSetFromMap(new IdentityHashMap<>());
Set<Object> seen = Collections.synchronizedSet(byIdentity);
Thread[] threads = new Thread[16];
for (int i = 0; i < threads.length; i++) {
threads[i] = Thread.ofPlatform().start(() -> {
try {
start.await();
seen.add(get.get());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
start.countDown();
for (Thread t : threads) t.join();
return seen.size();
}
public static void main(String[] args) throws InterruptedException {
LOADS.set(0);
race(Naive::get);
System.out.println("check, then create: loaded more than once: " + (LOADS.get() > 1));
LOADS.set(0);
int distinct = race(Settings::get);
int loads = LOADS.get();
System.out.println("holder idiom: loaded " + loads + " time;"
+ " threads got " + distinct + " object");
LOADS.set(0);
distinct = race(() -> Rates.INSTANCE.values);
loads = LOADS.get();
System.out.println("enum: loaded " + loads + " time;"
+ " threads got " + distinct + " object");
}
} TypeScript · compiled/lazy.ts
// One lazily loaded value shared by 16 concurrent async callers in TypeScript. JavaScript runs
// one task at a time, yet the same race appears: every caller that checks while the first load
// is awaited starts a load of its own.
// Run: node lazy.ts
type Rates = Map<string, string>;
let loads = 0;
async function load(): Promise<Rates> {
loads += 1;
await new Promise((resolve) => setTimeout(resolve, 50)); // reading a file or asking a server
return new Map([["currency", "INR"]]);
}
// 1. Cache the value: the check happens before the await, so all 16 callers see nothing cached.
let value: Rates | undefined;
async function cachedValue(): Promise<Rates> {
if (value === undefined) value = await load();
return value;
}
// 2. Cache the promise: every caller awaits the one load in flight; a failed load is forgotten
// so a later call retries.
let pending: Promise<Rates> | undefined;
function cachedPromise(): Promise<Rates> {
pending ??= load().catch((error: unknown) => {
pending = undefined;
throw error;
});
return pending;
}
async function main(): Promise<void> {
const first = await Promise.all(Array.from({ length: 16 }, () => cachedValue()));
console.log(
`cache the value: loaded ${loads} times; callers got ${new Set(first).size} objects`,
);
loads = 0;
const second = await Promise.all(Array.from({ length: 16 }, () => cachedPromise()));
console.log(
`cache the promise: loaded ${loads} time; callers got ${new Set(second).size} object`,
);
}
void main(); Go · compiled/lazy.go
// One lazily created value in Go: sync.OnceValue runs the function once, however many
// goroutines ask at the same time, and all of them get its result.
// Run with the race detector: go run -race lazy.go
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
var loads atomic.Int32
func load() *map[string]string {
loads.Add(1)
time.Sleep(50 * time.Millisecond) // reading a file or asking a server
return &map[string]string{"currency": "INR"}
}
var settings = sync.OnceValue(load)
func main() {
start := make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
seen := map[*map[string]string]bool{}
for range 16 {
wg.Go(func() {
<-start
s := settings()
mu.Lock()
seen[s] = true
mu.Unlock()
})
}
close(start)
wg.Wait()
fmt.Printf("sync.OnceValue: loaded %d time; goroutines got %d object\n", loads.Load(), len(seen))
} C++ · compiled/lazy.cpp
// One lazily created object in C++23: a function-local static is initialised once, and threads
// that arrive during the initialisation wait for it ([stmt.dcl]).
// Build with ThreadSanitizer, which reports any data race:
// clang++ -std=c++23 -g -fsanitize=thread lazy.cpp -o lazy
#include <atomic>
#include <chrono>
#include <latch>
#include <map>
#include <mutex>
#include <print>
#include <set>
#include <string>
#include <thread>
#include <vector>
std::atomic<int> loads{0};
std::map<std::string, std::string> load() {
++loads;
// reading a file or asking a server
std::this_thread::sleep_for(std::chrono::milliseconds(50));
return {{"currency", "INR"}};
}
const std::map<std::string, std::string>& settings() {
static const std::map<std::string, std::string> instance = load();
return instance;
}
int main() {
std::latch start{1};
std::mutex mu;
std::set<const void*> seen;
std::vector<std::jthread> threads;
for (int i = 0; i < 16; ++i) {
threads.emplace_back([&] {
start.wait();
const auto* s = &settings();
std::scoped_lock lock{mu};
seen.insert(s);
});
}
start.count_down();
threads.clear(); // jthread joins when it is destroyed
std::println("function-local static: loaded {} time; threads got {} object", loads.load(),
seen.size());
} Rust · compiled/lazy.rs
// One lazily created value in Rust: LazyLock runs its closure on first use, and threads that
// arrive meanwhile wait; OnceLock does the same with the closure given at the call.
// Build: rustc --edition 2024 lazy.rs
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Barrier, LazyLock, Mutex, OnceLock};
use std::thread;
use std::time::Duration;
static LOADS: AtomicUsize = AtomicUsize::new(0);
fn load() -> HashMap<&'static str, &'static str> {
LOADS.fetch_add(1, Ordering::SeqCst);
thread::sleep(Duration::from_millis(50)); // reading a file or asking a server
HashMap::from([("currency", "INR")])
}
static SETTINGS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(load);
static RATES: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
/// Calls get() from 16 threads released together; returns how many different objects they received.
fn race(get: fn() -> &'static HashMap<&'static str, &'static str>) -> usize {
let barrier = Barrier::new(16);
let seen = Mutex::new(HashSet::new());
thread::scope(|scope| {
for _ in 0..16 {
scope.spawn(|| {
barrier.wait();
let address = get() as *const _ as usize;
seen.lock().unwrap().insert(address);
});
}
});
seen.into_inner().unwrap().len()
}
fn main() {
let distinct = race(|| &SETTINGS);
println!(
"LazyLock: loaded {} time; threads got {} object",
LOADS.swap(0, Ordering::SeqCst),
distinct
);
let distinct = race(|| RATES.get_or_init(load));
println!(
"OnceLock: loaded {} time; threads got {} object",
LOADS.load(Ordering::SeqCst),
distinct
);
} compiled/languages_session.py
from session import run, versions
# One lazily created instance raced by 16 threads (or 16 async callers) in five languages. Go
# runs under its race detector and C++ under ThreadSanitizer, so a data race would be reported
# here.
versions("javac --version", "node --version", "go version", "clang++ --version", "rustc --version")
run("""
java Lazy.java
node lazy.ts
go run -race lazy.go
clang++ -std=c++23 -g -Wall -Wextra -fsanitize=thread lazy.cpp -o lazy && ./lazy
rustc --edition 2024 -o lazy_rs lazy.rs && ./lazy_rs
""") Output
# javac 25.0.4.1 # v24.21.0 # go version go1.27.1 darwin/arm64 # Apple clang version 21.0.0 (clang-2100.3.34.2) # rustc 1.99.0 (b940084d7) $ java Lazy.java check, then create: loaded more than once: true holder idiom: loaded 1 time; threads got 1 object enum: loaded 1 time; threads got 1 object $ node lazy.ts cache the value: loaded 16 times; callers got 16 objects cache the promise: loaded 1 time; callers got 1 object $ go run -race lazy.go sync.OnceValue: loaded 1 time; goroutines got 1 object $ clang++ -std=c++23 -g -Wall -Wextra -fsanitize=thread lazy.cpp -o lazy && ./lazy function-local static: loaded 1 time; threads got 1 object $ rustc --edition 2024 -o lazy_rs lazy.rs && ./lazy_rs LazyLock: loaded 1 time; threads got 1 object OnceLock: loaded 1 time; threads got 1 object
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 languages_session.py
compiled/session.py
"""Prints a terminal session: "$ " and each command, then everything it printed (standard output
and standard error together), and its exit status when that is not 0.
The toolchains are the pinned ones: Java 25 (Eclipse Temurin 25.0.4), Node.js 24.21.0 and Go
1.27.1 from the mise installations next to the Python that runs this file, Rust 1.99.0 from
its rustup toolchain, and the system's Apple clang. learn:verify cannot let a sandboxed run
read the home folder those installations live in, so the sessions of this folder are recorded
with LEARN_VERIFY_NETWORK=allow. They read only this folder and the toolchains, write only
inside this folder and the run's own temporary home, and never use the network.
A command that fails and is not listed in expect_failure makes the session exit with status
1, so a run that could not build a program never becomes a record."""
import os
import pwd
import re
import signal
import subprocess
import sys
from pathlib import Path
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
REAL_HOME = Path(pwd.getpwuid(os.getuid()).pw_dir)
TOOLCHAINS = [
INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin",
INSTALLS / "node" / "24.21.0" / "bin",
INSTALLS / "go" / "1.27.1" / "bin",
REAL_HOME / ".rustup" / "toolchains" / "1.99.0-aarch64-apple-darwin" / "bin",
]
# Version lines that end with a build date (rustc's) are shown without it: pages show no dates.
BUILD_DATE = re.compile(r"\s+\d{4}-\d{2}-\d{2}(?=\))")
def _env() -> dict[str, str]:
env = dict(os.environ)
env["PATH"] = os.pathsep.join([*map(str, TOOLCHAINS), env.get("PATH", "")])
env["GOTOOLCHAIN"] = "local" # never download another Go
env["GOTELEMETRY"] = "off"
env["GOFLAGS"] = "-trimpath"
return env
def _shell(command: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command, shell=True, env=_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
def versions(*commands: str) -> None:
"""Prints the first line of each version command as a comment."""
for command in commands:
done = _shell(command)
lines = done.stdout.splitlines()
if done.returncode != 0 or not lines:
sys.exit(f"{command} did not run: the pinned toolchain is missing here")
print("#", BUILD_DATE.sub("", lines[0]))
def run(script: str, expect_failure: tuple[str, ...] = ()) -> None:
"""Runs each line of the script as one shell command; only the commands in expect_failure
may fail."""
failed = 0
for command in (line.strip() for line in script.strip().splitlines()):
print(f"$ {command}")
done = _shell(command)
print(done.stdout, end="")
if done.returncode < 0:
print(f"[stopped by signal {signal.Signals(-done.returncode).name}]")
elif done.returncode != 0:
print(f"[exit status {done.returncode}]")
if done.returncode != 0 and command not in expect_failure:
failed += 1
if failed:
sys.exit(1) - Java. The unsynchronised check-then-create loads more than once. The holder idiom puts the instance in a
nested class: the JVM initialises a class the first time it is used, under the per-class initialisation lock that
JLS 12.4.2 describes, so the other threads wait and then see the finished field. An
enumwith one constant is shorter still, since the specification allows no instances of an enum class other than its constants (JLS 8.9). Double-checked locking also works, but only with avolatilefield: the memory model’s guarantee that a write to a volatile variable is seen by every later read of it is what keeps a thread from seeing a half-built object. - TypeScript. JavaScript runs one task at a time, yet caching the value loads 16 times: every caller checks
before the first
awaitresumes. Caching the promise makes every early caller wait for the one load in flight, and thecatchforgets a failed load so that a later call can try again. The exercise below builds the same idea in Python. - Go.
sync.OnceValue, added in Go 1.21, wraps a function so that it runs once and every caller gets its result;sync.Onceis the older form for code with side effects. The race detector reports nothing. - C++. A function-local
staticis initialised the first time control passes through its declaration, and the standard requires that a thread arriving during the initialisation waits for it to finish ([stmt.dcl]). It is the form that the Core Guidelines’ rule against singletons (I.3) allows as an exception, because it solves the order of initialisation without a class that hides its constructor. - Rust. A
staticLazyLock(stable since Rust 1.80) runs its closure on first use;OnceLock(stable since 1.70) takes the closure at the call, which suits values that need run-time input. Both make other threads wait during the initialisation, and both are whatstaticitems need to hold anything that cannot be built at compile time.
Stable values in JDK 25
JDK 25 also previews StableValue (JEP 502), an API for values that are initialised at most once, even in
multi-threaded programs, and then treated by the JVM like constants. Preview APIs need --enable-preview and may
change before they are final, so this lesson’s Java code uses only final features.
In Node.js: a package loaded twice
JavaScript modules are singletons too: Node.js caches ES modules by URL and CommonJS modules by their resolved file name, so importing one module twice gives one module object. A package that ships both formats breaks that, because its ES module build and its CommonJS build are two different files:
dual/main.js
// One package that ships both an ES module and a CommonJS build, loaded both ways in one
// program. package.json maps "#ratecard" to ratecard.mjs for import and to ratecard.cjs for
// require, as a dual package's "exports" does.
import { createRequire } from "node:module";
import { card as imported } from "#ratecard";
const require = createRequire(import.meta.url);
const { card: required } = require("#ratecard");
imported.rates.set("express", 4900);
console.log("import and require return one object:", imported === required);
console.log(
"express rate set through import, read through require:",
required.rates.get("express"),
);
console.log(`two module instances: ${imported.build} and ${required.build}`); Output
import and require return one object: false express rate set through import, read through require: undefined two module instances: ES module and CommonJS
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node main.js
dual/package.json
{
"name": "dual-ratecard-demo",
"private": true,
"type": "module",
"imports": {
"#ratecard": {
"import": "./ratecard.mjs",
"require": "./ratecard.cjs"
}
}
} dual/ratecard.mjs
// The ES module build of a package's rate card: one Map, created when the module is first
// loaded.
export const card = { build: "ES module", rates: new Map() }; dual/ratecard.cjs
// The CommonJS build of the same rate card, as a package that ships both formats contains it.
module.exports = { card: { build: "CommonJS", rates: new Map() } }; The program loads the package both ways, as an application and one of its dependencies easily do, and gets two rate cards: a rate set through the imported one is missing from the required one. The Node.js guide to publishing packages calls this the dual-package hazard and warns that a stateful package loaded in both formats ends up with parallel states. A package that holds state should ship one format, or keep the state in a single module that both builds use.
What a Singleton costs in tests
"""Two small tests of a price formatter, run in both orders: once with a settings singleton,
once with the settings passed in through the constructor."""
# ---- with a singleton ------------------------------------------------------------------------
class Settings:
_instance = None
def __init__(self):
self.currency = "INR"
@classmethod
def get(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
class GlobalFormatter:
def price(self, minor_units):
return f"{Settings.get().currency} {minor_units / 100:.2f}"
def global_test_dollars():
# the test changes the one shared instance and never changes it back
Settings.get().currency = "USD"
assert GlobalFormatter().price(250) == "USD 2.50"
def global_test_rupees():
assert GlobalFormatter().price(129900) == "INR 1299.00", GlobalFormatter().price(129900)
# ---- with the settings passed in -------------------------------------------------------------
class InjectedFormatter:
def __init__(self, currency):
self.currency = currency
def price(self, minor_units):
return f"{self.currency} {minor_units / 100:.2f}"
def injected_test_dollars():
assert InjectedFormatter("USD").price(250) == "USD 2.50"
def injected_test_rupees():
assert InjectedFormatter("INR").price(129900) == "INR 1299.00"
def run(tests, reset=None):
"""Runs tests in the given order in a fresh process state; returns a one-line report."""
if reset:
reset()
failures = []
for test in tests:
try:
test()
except AssertionError as error:
failures.append(f"{test.__name__} failed: got {error}")
return "; ".join(failures) or "all passed"
def fresh_process():
Settings._instance = None # what starting a new test process does
print("with a singleton:")
print(" dollars, then rupees:", run([global_test_dollars, global_test_rupees], fresh_process))
print(" rupees, then dollars:", run([global_test_rupees, global_test_dollars], fresh_process))
print("with the settings passed in:")
print(" dollars, then rupees:", run([injected_test_dollars, injected_test_rupees]))
print(" rupees, then dollars:", run([injected_test_rupees, injected_test_dollars])) Output
with a singleton: dollars, then rupees: global_test_rupees failed: got USD 1299.00 rupees, then dollars: all passed with the settings passed in: dollars, then rupees: all passed rupees, then dollars: all passed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order_matters.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
global_test_dollars changes the shared Settings and never changes it back. Run it first and the rupee test fails;
run it second and everything passes. Real suites hit this as tests that fail only in a certain order, only in
parallel runs, or only on the build server, and the usual patch is a “reset” method on the singleton that every test
must remember to call. The injected version needs no reset: each test builds the formatter with the currency it
wants.
One instance, reached two ways
Text description of the diagram
The diagram has two panels, one above the other.
- Singleton: Orders, Invoices and Reports each call Settings.get(), which returns one instance with global access. The arrows are labelled hidden, because nothing in the classes' constructors shows that they depend on Settings.
- Injection: main() creates one Settings object and passes it to Orders(settings), Invoices(settings) and Reports(settings). There is still exactly one instance, but every class states its dependency in its constructor, and a test can pass a different one.
The refactor keeps the single instance and moves its creation to one place. main(), the composition root, creates
one Settings when the program starts and passes it to every class that needs it through their constructors. There
is still exactly one instance in production, but each dependency is visible in a constructor, a test can pass a
different one, and changing the settings is no longer something any line of code can do from anywhere.
When one instance is the right design
- Configuration loaded once and never changed. Make it immutable and pass it in. The C++ Core Guidelines put it
simply: if you don’t want a global object to change, declare it
constorconstexpr. - Process-wide infrastructure with no business state, such as loggers or a metrics registry. Python’s logging registry is the model: one instance per name, created on first use, safe to share.
- A resource that must exist once, such as a connection pool. Create it at start-up in the composition root and pass it in; create it lazily only with one of the safe forms above.
What to avoid is the combination the catalogue’s version invites: mutable state, created lazily, reached through a global.
Exercise: one load for every early caller
Python Online Compiler Paste ratecard.py and its tests into the compiler to run them outside the lesson.Check yourself
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · Why do many teams avoid the Singleton pattern, and what do they use instead?
A Singleton makes two promises: one instance, and a global point of access to it. The first is often fine; the
second causes the trouble. Code that calls Settings.get() hides a dependency that its constructor does not show,
any code can change the shared state, and tests that change it affect each other, so they pass or fail depending on
their order. Lazy creation adds a race: two threads, or two async callers, can both see that no instance exists and
create one each. Teams therefore create the one instance at start-up, in one place, and pass it to the classes that
need it, which is dependency injection: there is still one instance, but every dependency is visible and a test can
pass its own. A good answer adds that immutable configuration and loggers are reasonable single instances.
Key takeaways
- Singleton’s “one instance” is often right; its global access is what hides dependencies and couples tests.
- Lazy creation needs a safe form: a lock around check and create in Python, the holder idiom or an enum in Java,
a cached promise in TypeScript,
sync.OnceValuein Go, a function-localstaticin C++,LazyLockorOnceLockin Rust. - Since Python 3.12,
functools.cached_propertytakes no lock; free-threaded Python makes such races easier to hit. - One “singleton” can become two: a Python module imported under two names, or a package loaded as both an ES module and CommonJS.
- Prefer one instance created at start-up and passed in through constructors; it keeps the single instance and drops the hidden global.
References
- Design Patterns: Elements of Reusable Object-Oriented Software (Singleton) (Addison-Wesley (Pearson))
- C++ Core Guidelines: I.3, avoid singletons (Standard C++ Foundation)
- C++ working draft: declaration statement ([stmt.dcl]) (ISO C++ committee (working draft))
- The Java Language Specification, Java SE 25: 8.9 Enum Classes (Oracle)
- The Java Language Specification, Java SE 25: 12.4.2 Detailed Initialization Procedure (Oracle)
- The Java Language Specification, Java SE 25: 17.4 Memory Model (Oracle)
- JEP 502: Stable Values (Preview) (OpenJDK)
- Python programming FAQ: how do I share global variables across modules? (Python Software Foundation)
- functools.cached_property (Python Software Foundation)
- logging.getLogger (Python Software Foundation)
- PEP 779: Criteria for supported status for free-threaded Python (Python Software Foundation)
- Package sync: Once and OnceValue (The Go Authors)
- std::sync::OnceLock (The Rust Project)
- std::sync::LazyLock (The Rust Project)
- Node.js v24: ECMAScript modules (URLs and caching) (OpenJS Foundation)
- Node.js v24: CommonJS modules (caching) (OpenJS Foundation)
- Publishing a package: the dual-package hazard (OpenJS Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress