Singleton, and why to inject instead
A singleton promises two things: exactly one instance, and access to it from anywhere. This page builds Pedal's pricing config as a singleton, watches it leak state between tests, then keeps the first promise and drops the second by creating one config at startup and passing it in.
1. The pain
Pedal's prices live in one configuration object: price per minute, members' free minutes, peak windows. Checkout, receipts and the monthly report all need the same values, and it would be wrong for two copies to disagree. "There must be exactly one" sounds like a singleton.
So the config became a singleton: PricingConfig.instance(), callable from anywhere. Then the test suite started failing at random. One test changed the price per minute to check a promotion; every test that ran after it in the same process saw the changed price. Nothing in price_member_ride(minutes)'s signature says it reads a global, so the cause took a day to find.
2. The idea in one picture
Singleton makes a class responsible for having only one instance and for providing a global way to reach it: a private constructor plus a static instance() method. It solves two problems at once: "only one" and "reachable from anywhere". The first is often a real need; the second is the source of most of the trouble.
The usual fix keeps the first and drops the second: create the one instance at startup (the composition root) and pass it to the objects that need it through their constructors. The running app still has exactly one config; tests make their own. This is dependency injection, covered fully on page 24.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Singleton | Holds its single instance and hides its constructor. | PricingConfig with instance() |
| Client | Reaches the instance through the static method. | price_member_ride() |
| Composition root (the alternative) | Creates the one instance at startup and passes it on. | main() building Pricer(shared) and ReceiptWriter(shared) |
| Injected client (the alternative) | Receives the instance in its constructor. | Pricer(config) |
4. Before and after
The same "one config" requirement, two ways (Python sketch; the full program is in section 5).
Without the pattern
class PricingConfig:
_instance = None
@classmethod
def instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def price_member_ride(minutes):
cfg = PricingConfig.instance()
...With it
class Pricer:
def __init__(self, config):
self.config = config
def member_ride(self, minutes):
...
# at startup, once:
shared = Config()
checkout = Pricer(shared)
receipts = ReceiptWriter(shared)| The next change | Without the pattern | With it |
|---|---|---|
| A test needs 12p a minute | Change the global, and hope every later test resets it | Pricer(Config(per_minute=12)), private to that test |
| Paris needs different prices in the same process | Impossible: there is only one instance | Pass Paris's config to Paris's pricer |
| Find what depends on the config | Search for instance() calls in bodies | Read the constructors |
Injection keeps "one instance in production" and drops "reachable from anywhere", which is the part that hurts.
5. The code
A lazily created singleton read by price_member_ride(), two tests that interfere through it, and the injected version where each test owns its config while the app still shares one.
from __future__ import annotations
from dataclasses import dataclass
# ---- The classic singleton: one instance, reachable from anywhere. ----
class PricingConfig:
_instance: PricingConfig | None = None
def __init__(self) -> None:
self.per_minute = 10
self.free_minutes = 30
@classmethod
def instance(cls) -> PricingConfig:
if cls._instance is None: # created on first use
cls._instance = cls()
return cls._instance
def price_member_ride(minutes: int) -> int:
cfg = PricingConfig.instance() # a dependency you can't see from the signature
return max(0, minutes - cfg.free_minutes) * cfg.per_minute
# ---- The alternative: still one config in the running app, but handed to whoever needs it. ----
@dataclass
class Config:
per_minute: int = 10
free_minutes: int = 30
class Pricer:
def __init__(self, config: Config) -> None:
self.config = config # the dependency is in the constructor
def member_ride(self, minutes: int) -> int:
return max(0, minutes - self.config.free_minutes) * self.config.per_minute
class ReceiptWriter:
def __init__(self, config: Config) -> None:
self.config = config
if __name__ == "__main__":
print(f"singleton: same object every time -> {PricingConfig.instance() is PricingConfig.instance()}")
# Two tests that share the process, run one after the other.
PricingConfig.instance().per_minute = 12 # test A changes the price
print(f"test A sets 12p a minute: 42-minute ride costs {price_member_ride(42)}p")
got = price_member_ride(42) # test B expects the default
print(f"test B expects 120p, gets {got}p -> {'pass' if got == 120 else 'FAIL (test A leaked)'}")
a, b = Pricer(Config(per_minute=12)).member_ride(42), Pricer(Config()).member_ride(42)
print(f"injected: test A gets {a}p, test B gets {b}p -> {'both pass' if (a, b) == (144, 120) else 'FAIL'}")
shared = Config() # the composition root makes one
checkout, receipts = Pricer(shared), ReceiptWriter(shared)
print(f"app wiring: checkout and receipts share one config -> {checkout.config is receipts.config}")
// ---- The classic singleton: one instance, reachable from anywhere. ----
class PricingConfig {
private static only: PricingConfig | undefined;
perMinute = 10;
freeMinutes = 30;
private constructor() {}
static instance(): PricingConfig {
if (!PricingConfig.only) PricingConfig.only = new PricingConfig(); // created on first use
return PricingConfig.only;
}
}
function priceMemberRide(minutes: number): number {
const cfg = PricingConfig.instance(); // a dependency you can't see from the signature
return Math.max(0, minutes - cfg.freeMinutes) * cfg.perMinute;
}
// ---- The alternative: still one config in the running app, but handed to whoever needs it. ----
class Config {
constructor(public perMinute = 10, public freeMinutes = 30) {}
}
class Pricer {
constructor(readonly config: Config) {} // the dependency is in the constructor
memberRide(minutes: number): number {
return Math.max(0, minutes - this.config.freeMinutes) * this.config.perMinute;
}
}
class ReceiptWriter {
constructor(readonly config: Config) {}
}
console.log(`singleton: same object every time -> ${PricingConfig.instance() === PricingConfig.instance() ? "True" : "False"}`);
// Two tests that share the process, run one after the other.
PricingConfig.instance().perMinute = 12; // test A changes the price
console.log(`test A sets 12p a minute: 42-minute ride costs ${priceMemberRide(42)}p`);
const got = priceMemberRide(42); // test B expects the default
console.log(`test B expects 120p, gets ${got}p -> ${got === 120 ? "pass" : "FAIL (test A leaked)"}`);
const a = new Pricer(new Config(12)).memberRide(42), b = new Pricer(new Config()).memberRide(42);
console.log(`injected: test A gets ${a}p, test B gets ${b}p -> ${a === 144 && b === 120 ? "both pass" : "FAIL"}`);
const shared = new Config(); // the composition root makes one
const checkout = new Pricer(shared), receipts = new ReceiptWriter(shared);
console.log(`app wiring: checkout and receipts share one config -> ${checkout.config === receipts.config ? "True" : "False"}`);public class Main {
// ---- The classic singleton: one instance, reachable from anywhere. ----
static final class PricingConfig {
int perMinute = 10;
int freeMinutes = 30;
private PricingConfig() {}
// The holder idiom: created on first use, and thread-safe because class loading is.
private static final class Holder {
static final PricingConfig INSTANCE = new PricingConfig();
}
static PricingConfig instance() { return Holder.INSTANCE; }
}
static int priceMemberRide(int minutes) {
PricingConfig cfg = PricingConfig.instance(); // a dependency you can't see from the signature
return Math.max(0, minutes - cfg.freeMinutes) * cfg.perMinute;
}
// ---- The alternative: still one config in the running app, but handed to whoever needs it. ----
static final class Config {
int perMinute, freeMinutes;
Config() { this(10, 30); }
Config(int perMinute, int freeMinutes) { this.perMinute = perMinute; this.freeMinutes = freeMinutes; }
}
static final class Pricer {
final Config config;
Pricer(Config config) { this.config = config; } // the dependency is in the constructor
int memberRide(int minutes) { return Math.max(0, minutes - config.freeMinutes) * config.perMinute; }
}
static final class ReceiptWriter {
final Config config;
ReceiptWriter(Config config) { this.config = config; }
}
static String py(boolean b) { return b ? "True" : "False"; }
public static void main(String[] args) {
System.out.println("singleton: same object every time -> " + py(PricingConfig.instance() == PricingConfig.instance()));
// Two tests that share the process, run one after the other.
PricingConfig.instance().perMinute = 12; // test A changes the price
System.out.println("test A sets 12p a minute: 42-minute ride costs " + priceMemberRide(42) + "p");
int got = priceMemberRide(42); // test B expects the default
System.out.println("test B expects 120p, gets " + got + "p -> " + (got == 120 ? "pass" : "FAIL (test A leaked)"));
int a = new Pricer(new Config(12, 30)).memberRide(42), b = new Pricer(new Config()).memberRide(42);
System.out.println("injected: test A gets " + a + "p, test B gets " + b + "p -> " + (a == 144 && b == 120 ? "both pass" : "FAIL"));
Config shared = new Config(); // the composition root makes one
Pricer checkout = new Pricer(shared);
ReceiptWriter receipts = new ReceiptWriter(shared);
System.out.println("app wiring: checkout and receipts share one config -> " + py(checkout.config == receipts.config));
}
}Output (the same in all three languages)
singleton: same object every time -> True test A sets 12p a minute: 42-minute ride costs 144p test B expects 120p, gets 144p -> FAIL (test A leaked) injected: test A gets 144p, test B gets 120p -> both pass app wiring: checkout and receipts share one config -> True
6. Try it
Run the test suite
Four tests share one process. Two of them change the config. Run them in different orders, with a singleton and with injection.
A: sets 12p a minute, expects a 42-minute member ride to cost 144p. B: expects the default, 120p. C: sets 45 free minutes, expects 0p. D: expects a 50-minute ride to cost 200p.
7. When not to use it
What you get
- Exactly one instance: when the domain truly needs it (one connection pool, one hardware handle).
- Lazy creation: built on first use.
- No wiring: any code can reach it without being passed it.
- Familiar: everyone recognises the shape.
What you pay
- Hidden dependencies: signatures don't show what a function reads.
- Global mutable state: one change is seen everywhere, including other tests.
- Hard to test: you can't easily give one test a different instance.
- Concurrency traps: lazy creation must be made thread-safe; shared state must be too.
| If… | Use instead |
|---|---|
| You need one instance in the running app | Create it once at startup and inject it: 24 Dependency injection |
| The values never change after startup | An immutable config object, passed in |
| You need one instance per city, tenant or request | A registry or scoped objects, not a singleton |
| You want a few shared, swappable services | A DI container's "singleton scope": one instance, still injected |
8. How it goes wrong
None and create two instances. Use the language's safe idiom (Java's holder class or enum), or create it eagerly.instance() calls hide a whole dependency graph that only shows up at runtime.9. Remember it and say it
How this hook works: what “One clock on the wall” means
What the slogan means, phrase by phrase
- “One clock”
- The single instance.
- “on the wall”
- Reachable from anywhere via
instance(). - “anyone can move”
- Global mutable state: one change is everyone's change.
Why this picture fits: A shared wall clock shows both the convenience and the danger: one source, but one mistake moves everyone.
The story → the concept
| The office | The code |
|---|---|
| The wall clock | PricingConfig.instance() |
| Moving it to test the alarm | Test A setting 12p a minute |
| Everyone late afterwards | Test B getting 144p |
| A watch from reception | An injected Config |
Watch it on an example
- Test A sets the singleton's price to 12p a minute.
- Test B runs next in the same process and expects the default.
- It gets 144p instead of 120p, because both tests read the same object.
The trap it saves you from
Believing "only one" requires "reachable from anywhere".
It still works when…
Yes: logging, metrics and connection pools are the classic acceptable singletons, because they're shared and rarely change behaviour. Even those are easier to test when injected.
In an interview or code review
- Separate the two promises: "We need one config in production, but not global access; I'll create it at startup and inject it."
- If you do write one, make it thread-safe (Java: holder idiom or
enum; Python: a module-level instance created at import) and keep it immutable. - Name the cost precisely: hidden dependencies and shared state between tests, not "singletons are bad".
- Mention scopes: DI containers offer singleton scope, which gives one instance without global access.
10. Check yourself
Which two things does a classic singleton promise?
Test A changes the singleton; test B then fails. Why?
How do you keep "one instance" but drop global access?
What's a thread-safe lazy singleton in Java?
Which is the most acceptable use of a singleton?
11. Go deeper
| Resource | Why |
|---|---|
| 24 Dependency injection | The full alternative: composition roots, constructor injection and containers. |
| Low-level design 03e: Dependency inversion | Why depending on what you're given beats reaching out for it. |
| Python FAQ: sharing globals across modules | The module itself is Python's built-in single instance. |
| Wikipedia: Singleton pattern | Implementations and the criticism. |