Appearance
9.4.6 — Singleton (and What to Use Instead)
What the original Gang of Four book says: Make sure a class has only one instance, and give everyone one global way to reach it.
What that means when you are actually writing code: Read that sentence again. It makes two promises joined by "and" — and almost every problem with this pattern comes from the fact that you usually want only the first one.
Singleton is the most-asked and most-misused pattern in the whole catalogue. It is also the one where a strong answer separates candidates instantly, because the strong answer is not "here is how to write one". It is "here is the decision it secretly bundles together, here is which half I actually need, and here is how I get that half without the other." This chapter teaches the pattern completely, including the correct implementations you may be asked to write, and then teaches its replacement in equal depth — because the replacement, the composition root, is a concept the rest of Part 9 leans on.
1. The story: the logger that leaked a customer's data
A team adds logging. The logger needs configuration — the level, the destination, the redaction rules — so it should be built once:
typescript
class Logger {
static #instance: Logger | null = null;
private constructor(private readonly cfg: LogConfig) {}
static getInstance(): Logger {
Logger.#instance ??= new Logger(loadConfig());
return Logger.#instance;
}
info(msg: string, ctx?: object) { /* … */ }
}
// anywhere, in any file, at any depth:
Logger.getInstance().info("charge succeeded", { orderId });For a year this is fine. Then the product goes multi-tenant, and each tenant needs its own redaction rules — tenant A is in the EU and must scrub email addresses; tenant B is not. Somebody makes the sensible-looking change: initialize the logger with the tenant's rules at the start of each request.
typescript
Logger.initialize(tenant.logConfig); // ← at the top of the request handlerThe first request of the process wins. Because getInstance() returns whatever was created first — or because "initialize" overwrites a logger that concurrent requests are already using — tenant A's traffic gets logged with tenant B's redaction rules, and unredacted EU email addresses land in a log aggregator in another jurisdiction. It is a compliance incident, and the code that caused it is four lines of a textbook pattern.
Trace the chain of cause, because every link is a general lesson.
First, the logger's lifetime ("one exists") and its access ("reachable from anywhere") were bound together by the pattern into a single mechanism.
Second, because access was global, no signature anywhere declared that it depended on the logger. So when the requirements changed, there was no list of affected call sites to inspect. The dependency was invisible to the compiler, to code review, and to any architecture diagram.
Third, because the lifetime was hard-coded to "one per process", changing it to "one per tenant" was not a configuration change — it was surgery across the whole codebase, so somebody chose the four-line hack instead.
Fourth, because the instance is per process, the behaviour differed across cluster workers (3.8.6) — which made it intermittent, impossible to reproduce locally, and a nightmare to diagnose.
2. How you arrive at the pattern — and why the derivation derails it
Step 1 — Start naive. const logger = new Logger(cfg) in whatever file needs it. This is fine, except that now every file builds its own with its own configuration.
Step 2 — Wait for the force. "There should be exactly one." One connection pool (because a pool is the mechanism that bounds concurrent connections, and two pools of 20 mean 40). One configuration object (so a value cannot differ between readers). One metrics registry (so the counters aggregate).
Step 3 — Draw the line between what varies and what stays fixed. Here the derivation diverges from the pattern, and this is the intellectual heart of the page. There are actually two separate questions hiding inside "there should be exactly one".
| Question | Kind of decision | Who should answer it |
|---|---|---|
| How many instances exist? | a lifetime decision | the code that assembles the application |
| How does code get to one? | an access decision | each consumer's interface — that is, its parameters |
The Gang of Four's Singleton answers both questions with one mechanism: a static accessor. That coupling is the defect. Lifetime is legitimately a global decision; access is legitimately a local one. Everything below follows from separating the two.
Step 4 — Decide when the choice is made. Static, at first use, forever — which is exactly why the pattern resists the requirement changes that arrive later ("per tenant", "per request", "per test").
Step 5 — Name the pattern and say what it costs. The name is Singleton. The costs are four, and you should be able to recite them (section 4). But state the pattern's genuine value too, because a critique that admits nothing is not credible. Singleton is the simplest possible answer to "one exists and everyone can reach it", it requires no wiring, and in a small script or a CLI tool that will never be tested or made multi-tenant, it is not worth arguing about.
3. The mental model
In one sentence: a Singleton is a global variable wearing a class costume — and the costume hides the two decisions it made on your behalf.
The analogy that makes it stick — the office printer. "There is one printer" is a fact about the office (lifetime), decided once by whoever set up the office. "Anyone may walk up to it from anywhere" is a policy (access). These are independent: you could have one printer that only the print-service handles requests for. Singleton fuses them, and then it makes both invisible — nobody's job description mentions the printer, so when the company opens a second floor and needs a second printer, there is no list of who was using the first one.
When you hear the trigger — and how to redirect it. When you hear "there must be exactly one X", the correct reflex is not "write a Singleton". It is a two-part question: "one per what — process, tenant, request, or test?" and "who needs it, and can they receive it?" Those two questions dissolve most Singleton proposals into a constructor parameter.
4. The four-part critique (recite this in interviews)
First, hidden global coupling. Logger.getInstance() inside a function means that function has a dependency no signature declares. It is invisible to the compiler, to reviewers, to dependency graphs, and to anyone trying to estimate the blast radius of a change. This is the worst coupling rung in 9.1's hierarchy, and it is the root cause from which the other three grow.
Second, test hostility. Static state survives across tests in the same process, so test order changes results — which is the definition of a flaky suite. Substituting a fake requires monkey-patching a static or adding a reset() method that exists only for tests (production code shaped by test needs, and a footgun in production). And you cannot run tests for two configurations in parallel in one process, which quietly halves your test throughput.
Third, conflated decisions that change independently. "Exactly one exists" and "reachable from anywhere" have different natural lifetimes, and both of them change: multi-tenancy changes the first, and modularization changes the second. The pattern hard-codes both at once, so either requirement change becomes a refactor of every call site — which is precisely why section 1's team reached for a hack.
Fourth, the multi-process lie. "Singleton" means one per process, not one per system. Under cluster, or multiple containers (3.8.6), every worker constructs its own. Concretely: a "unique" ID counter emits duplicates; an in-memory rate limiter with a 100 rps cap admits 100 × workers; a cache diverges per worker, so users see alternating stale and fresh data. The name actively misleads here, and this is the point most commonly missed in interviews — say it and you separate yourself.
And in JavaScript specifically there is a fifth, subtler failure: a "singleton" can be built more than once per process if the module is loaded twice. Different resolved paths, a duplicated dependency under node_modules, CommonJS and ESM copies of the same package, or a bundler emitting it into two chunks all cause this. The module cache is keyed by resolved path, not by package identity (3.6.5). Libraries that truly need process-wide uniqueness resort to a Symbol.for() key on globalThis — which is worth knowing precisely because it shows how weak the guarantee really is.
5. The replacement: the composition root
The composition root is the single place — at application startup — where the object graph is built and wired together. Below it, code receives its collaborators and never builds volatile ones itself.
typescript
// main.ts — the composition root. The ONLY file that decides lifetimes.
async function main() {
const config = loadConfig(); // (1) once, eagerly — no lazy race
const logger = createLogger(config.log); // (2) "exactly one" lives HERE
const pool = new PgPool(config.db); // …and here
const cache = new RedisCache(config.redis);
const orders = new OrderRepository(pool, logger); // (3) dependencies flow DOWN
const payments = new PaymentService(createGateway(config), orders, logger);
const app = buildHttpApp({ payments, orders, logger, cache });
const server = app.listen(config.port);
installShutdownHandlers(server, [pool, cache, logger]); // (4) lifetimes end here too
}
main();(1) Eager construction removes the lazy-initialization race entirely. No getInstance(), no double-checked locking, no "who created it first" question. The object exists before anything can ask for it, and a failure to construct fails the deploy rather than the first unlucky request.
(2) "Exactly one" is preserved, and it is now visible in one file. This is the crucial point people miss when they hear "don't use Singleton": you are not giving up singleness. You are giving up the static accessor.
(3) Every dependency is a parameter, so it is declared in a type, checked by the compiler, visible in review, and replaceable in tests with a literal object. The dependency graph becomes readable by reading one file.
(4) Shutdown gets a home too. Singletons are notoriously hard to close in the right order; a composition root that constructs in dependency order can tear down in reverse (9.9.7).
Why this is strictly better, in one line each: you keep the same one instance; dependencies are declared instead of hidden; lifetime is changeable in one file (per-tenant becomes a Map<TenantId, Deps> built at the root, and per-request becomes construction in middleware); tests pass fakes as arguments with no globals to reset; and the multi-process reality is unchanged but now visible, because you can see that this file runs once per worker.
The health check for any codebase, and it takes ten seconds: grep -rn "new \(Pool\|Client\|Service\)" src/. Concentrated in one wiring layer means disciplined; scattered through the handlers means creation knowledge has spread into coupling.
The honest residue — module-level instances. In JavaScript, a module-level export const logger = createLogger(cfg) is a de facto singleton via module caching (3.6.5), and it is the most common real-world compromise. It is acceptable where the object is stateless or effectively stateless, app-wide, and never needs to differ per tenant, per request, or per test — a pure formatter, a constants table. It is not acceptable for anything holding connections, caches, per-tenant configuration, or state a test would want to inspect. The tell: if you have ever wanted to reset it between tests, it should have been injected.
6. Writing it correctly (because you will be asked to)
6.1 TypeScript, lazy
typescript
class AppConfig {
static #instance: AppConfig | null = null;
private constructor(readonly values: ConfigValues) {} // (1) nobody else can `new`
static instance(): AppConfig {
AppConfig.#instance ??= new AppConfig(loadConfig()); // (2) lazy: the first ask creates
return AppConfig.#instance;
}
}The private constructor (1) is what enforces the "only one" claim; without it, the class is merely offering a shared instance rather than guaranteeing one. The ??= (2) is the lazy-initialization idiom. In JavaScript this is race-free within one event-loop turn, because the language is single-threaded (3.6.8) — but if the initializer is async, two concurrent callers can both start it. The fix is to cache the promise, not the value: static #p: Promise<X> | null; static get() { return (this.#p ??= build()); }.
6.2 Eager, and why it is usually better
typescript
class MetricsRegistry {
static readonly instance = new MetricsRegistry(); // built at module evaluation
private constructor() {}
}No race, no branch, no null check. The only reasons to prefer lazy are genuinely expensive construction that may never be needed, or construction that depends on configuration not yet available at import time — and that second reason is usually a sign that the object belongs in the composition root instead.
6.3 Other languages, for interview completeness
Java's classic problem and its two idiomatic answers. A naive lazy getInstance() is a real data race with multiple threads. The historical fix is double-checked locking, which requires the volatile keyword to be correct — without it, another thread can observe a partially-constructed object because of instruction reordering, a genuinely subtle bug. The two clean alternatives are the initialization-on-demand holder idiom (a private static nested class, whose class-loading laziness the JVM guarantees) and the enum singleton (enum Config { INSTANCE; }), which Joshua Bloch recommends because the JVM guarantees a single instance even against reflection and serialization attacks. Knowing why the enum wins — reflection and deserialization can otherwise forge a second instance — is the detail that signals depth.
Python conventionally uses a module (imported once, cached in sys.modules) rather than a class; when a class is required, __new__ or a metaclass enforces it, and the "Borg" or monostate variant shares __dict__ across instances so that many objects behave as one. Note that Python's module cache is per-interpreter, so multiprocessing reproduces the multi-process lie exactly.
7. When Singleton is genuinely fine
A critique with no exceptions is dogma. These pass.
- Stateless utilities. A formatter, a validator, a compiled regex table. There is no state to leak, so tests do not care and tenants cannot interfere.
- Framework- or container-managed "singleton scope". NestJS, Angular, or Spring providers registered as singletons are exactly the replacement pattern: the container owns the lifetime, and access is by injection. It is called Singleton and behaves as a composition root — do not confuse the label with the anti-pattern.
- Genuinely process-global resources you do not own.
process,console, the module registry. These are singletons because the runtime says so. - Small scripts and CLIs with no tests, no tenants, no concurrency. Engineering judgment includes knowing when the argument is not worth having.
- Deliberate process-wide coordination points, where a second instance would itself be a bug — an OpenTelemetry provider, a signal-handler registry, a native library binding that must be initialized once. Even here, prefer constructing it once at the root and injecting it, and treat the global registration as an implementation detail of the root.
8. Variants
| Variant | Shape | Notes |
|---|---|---|
| Lazy | getInstance() with a null check | cache the promise if construction is async |
| Eager | static readonly instance = new X() | no race; prefer when construction is cheap |
| Module-level const | export const x = create() | the JS default; fine for stateless, app-wide objects |
| Multiton | Map<Key, Instance> — one per key | one per tenant, per shard, per region (9.4.2) |
| Container singleton scope | DI-managed lifetime | the good version: lifetime managed, access injected |
| Monostate / Borg | many instances, shared state | hides globality behind normal-looking objects — rarely a good trade |
globalThis[Symbol.for(…)] | cross-module-copy singleton | a last resort when duplicate module loads are real |
The multiton deserves emphasis, because it is what section 1's team actually needed: not "one logger" but "one logger per tenant". Once you have a composition root, that is a Map and a lookup — a change confined to one file — whereas under a Singleton it was surgery across the whole codebase. This is the concrete payoff of separating lifetime from access, and the best single example to give in an interview.
9. Ways to get it wrong
- Using it for anything stateful or per-tenant. The section 1 incident, in one line.
- Assuming process-wide means system-wide. Cluster workers, containers, and serverless instances each have their own. Counters duplicate, limits multiply, caches diverge.
- Lazy async initialization races. Two callers both start construction; two pools exist. Cache the promise.
- A
reset()for tests shipped in production code. A method that can be called at runtime to wipe shared state. - Duplicate module loads. Two copies of the package, two "singletons" (3.6.5).
- A Singleton holding request state. Any per-request field on a shared instance is a cross-request data leak — the security-flavoured version of the same bug (
app.localsmisuse, 9.9.2). - Shutdown ordering. Singletons created implicitly are destroyed in an undefined order; a logger closed before the pool means the pool's shutdown errors vanish.
- A Singleton used as a container —
Registry.getInstance().get("emailer")is the Service Locator anti-pattern with an extra step: dependencies invisible, resolution failures at runtime.
10. Singleton compared with its neighbours
| Compared with | The difference | Choose Singleton when |
|---|---|---|
| Composition root | the same singleness, injected access instead of static | never, if a root is available — the root strictly dominates |
| Prototype | share one versus copy many | the instance is not mutated per use |
| Factory Method | "how many" versus "which class" | different questions — they combine |
| Monostate | one instance versus many instances sharing state | you want the singleness to be visible rather than disguised |
| Multiton | one versus one-per-key | the key space is genuinely a single element |
| Static class of functions | no instance at all | there is state; if there is none, static functions are simpler |
11. Interview calibration
The 45-second answer, in the order you would say it:
Singleton conflates two decisions: 'exactly one instance exists', which is a lifetime decision, and 'anyone can reach it from anywhere', which is an access decision. I keep the first and drop the second — construct it once at the composition root and inject it. That gives the same singleness with dependencies declared in signatures, tests that pass fakes without resetting globals, and the ability to change the lifetime later — per tenant or per request — by editing one file instead of every call site.
I'd also point out the name misleads: it's one per process, so under cluster or containers a 'singleton' rate limiter admits N times its limit, and in JavaScript a duplicated module can produce two of them. Where it's genuinely fine: stateless utilities, runtime-owned globals, and a DI container's singleton scope — which is really the composition root under another name.
If asked to write one, I'll write it correctly — private constructor, promise-cached lazy init — and then say what I'd do instead.
Follow-up questions, with the seed of each answer:
- "Write a thread-safe one in Java." — Double-checked locking with
volatile(explain the reordering hazard), or better: the initialization-on-demand holder, or the enum singleton, which also survives reflection and deserialization attacks. - "How is a connection pool not a Singleton?" — It is one instance, constructed at the root and injected. The pool's singleness is a lifetime fact; nothing requires a static accessor.
- "What breaks first under multi-tenancy?" — Any per-tenant state on the shared instance; the fix is a multiton keyed by tenant, which is one file under a composition root and a rewrite under a Singleton.
- "Isn't dependency injection just a fancier global?" — No: the dependency is declared in the type, resolved in one place, and substitutable per instantiation. Globality is about reachability, not about being shared.
Recall
- The critique in one sentence: Singleton conflates lifetime ("exactly one exists") with access ("reachable from anywhere") — and you almost always want only the first. Four costs: hidden global coupling (no signature declares it), test hostility (shared state across tests, and every workaround is production risk or test cost), conflated decisions that change independently (multi-tenancy changes lifetime, and the hard-coded access makes it surgery across the codebase), and the multi-process lie (one per process — under cluster or containers a 100 rps limiter admits 100 × N, counters duplicate, caches diverge).
- The replacement is the composition root: one startup file builds config, pools, clients, and services once, eagerly, in dependency order, then passes them down as ordinary parameters; below it, no
newof volatile classes, and shutdown runs in reverse order. Same singleness; declared dependencies; fakes are literals; changing "one per what" is one file. Health check:grepfornewof service classes — concentrated means disciplined, scattered means diffused coupling. - JavaScript specifics: a module-level
export constis a de-facto singleton via module caching — acceptable only for stateless, app-wide objects (the tell: if you ever wanted to reset it between tests, it should have been injected). Duplicate module copies (CJS and ESM, nestednode_modules, bundler chunks) can create two "singletons";globalThis[Symbol.for(…)]is the last-resort fix. Async lazy init must cache the promise, not the value, or two callers build two instances. - Correct implementations, since you may be asked: private constructor plus
??=(TS); eagerstatic readonly instance(no race — prefer it); Java double-checked locking requiresvolatile, with the initialization-on-demand holder and the enum singleton as the clean answers (the enum also defeats reflection and deserialization forgery); Python uses modules, or__new__/metaclass, or Borg. - Legitimately fine: stateless utilities, runtime-owned globals (
process,console), a DI container's "singleton scope" (which is the composition root), small scripts. The variant that usually is the real requirement: the multiton — one instance per tenant, shard, or region, aMapand a lookup under a composition root, a rewrite under a Singleton.
Self-test: Name the two decisions Singleton fuses and which one you keep. Recite the four costs, including the one most candidates miss. What exactly does a composition root give you that a static accessor cannot? Why must async lazy initialization cache the promise? Give two ways a JavaScript "singleton" can exist twice in one process.
Quiz Bank
FoundationalState the Singleton critique as four named design violations, including the Node-specific one.
First, hidden global coupling. Logger.getInstance() reachable anywhere means every consumer has a dependency no signature declares — invisible to the compiler, to code review, to dependency graphs, and to anyone estimating a change's blast radius. This is 9.1's worst coupling rung, and the root from which the others grow.
Second, test hostility. Static state persists across tests in a process, so test order changes results; substituting a fake requires monkey-patching a static or adding a reset() that ships to production; tests for two configurations cannot run in parallel in one process. Every available workaround trades production risk against test cost, which is itself the diagnosis.
Third, conflated decisions that change independently. "Exactly one exists" is a lifetime decision and "reachable from anywhere" is an access decision; multi-tenancy changes the first (per-tenant configuration is a routine requirement), and modularization changes the second. Because the pattern hard-codes both, either change becomes surgery across the codebase — which is exactly why teams reach for the four-line hack that causes incidents.
Fourth, the multi-process lie. "Singleton" means one per process, not one per system: under cluster, multiple containers, or serverless instances (3.8.6) each worker constructs its own, so a "unique" counter duplicates, an in-memory rate limiter with a 100 rps cap admits 100 × workers, and caches diverge so users see alternating stale and fresh data. Uniqueness beyond one process requires external coordination — Redis, a database, the subject of Part 10.
The one-line replacement: keep the lifetime at the composition root, replace the access with injection.
FoundationalWhat is a composition root, and why does it dominate the Singleton for every requirement that arrives later?
The composition root is the single startup location where the object graph is wired: build configuration, pools, clients, and services — each once, eagerly, in dependency order — then hand the assembled graph to the entry point; below it, code receives collaborators and never builds volatile classes. It preserves everything Singleton offered and gives up only the static accessor.
Why it dominates on the requirements that actually arrive. "Make it per tenant": the root becomes a Map<TenantId, Deps> and a lookup, and consumers are unchanged because they already take a parameter. Under a Singleton this is a rewrite of every call site. "Make it per request": construction moves into middleware, and again consumers are unchanged. "Let us test two configurations in parallel": two graphs in one process, no globals, no reset. "Shut down cleanly": the root constructed in dependency order, so it can close in reverse — a logger closed after the pool it reports on, which implicit singletons cannot guarantee (9.9.7).
Additional properties worth naming: eager construction eliminates lazy-init races and turns a failed dependency into a failed deploy rather than a failed first request; every dependency becomes visible in a type, so the compiler enforces the architecture; and the whole graph is readable by reading one file, which is a genuine onboarding asset.
The residue to be honest about: a module-level export const is a de-facto singleton via module caching, and it is fine for stateless app-wide objects — but the moment you want to reset it between tests, it should have been injected.
AppliedWrite a correct lazy Singleton in TypeScript whose construction is asynchronous, and explain the specific bug the naive version has.
The naive version caches the value, and with async construction that leaves a window in which two callers both see "not created yet": if (!inst) inst = await build(); — caller A awaits inside build(), the event loop yields, caller B evaluates !inst (still null, because A has not resumed to assign), and B starts a second build. The result is two pools, two connections, or two registrations — intermittent, load-dependent, and invisible in single-request testing. Note that this is a genuine race in single-threaded JavaScript, which surprises people: the interleaving happens at await points, not between threads (3.6.8).
The fix is to cache the promise, so the second caller awaits the first's in-flight work:
typescript
class SearchClient {
static #p: Promise<SearchClient> | null = null;
private constructor(private readonly conn: Conn) {}
static get(): Promise<SearchClient> {
return (SearchClient.#p ??= (async () => {
const conn = await connect(config.url);
await conn.ping(); // verify before anyone can use it
return new SearchClient(conn);
})());
}
}Two refinements to mention: if construction fails, the rejected promise is now cached forever, so either clear #p in a .catch to allow a retry or deliberately keep the failure sticky (a fail-fast policy) — decide explicitly rather than by accident. And the honest closing note: this whole mechanism disappears if the object is built once at the composition root, where await at startup is natural and the "who built it first" question never arises — which is why the async case is a particularly strong argument for the root.
InterviewYour interviewer says a database connection pool is a legitimate Singleton. Agree or disagree, precisely.
Agree with the requirement and disagree with the mechanism — and make the distinction explicit, because that is the whole answer. The requirement is real: a pool exists to bound concurrent connections, and two pools of 20 give the database 40, silently defeating the limit that motivated pooling in the first place; so "exactly one per process" is a genuine constraint. But that is a lifetime statement, and nothing about it requires a static accessor.
Building new PgPool(config.db) once in the composition root and injecting it gives identical singleness with four concrete advantages: repositories declare the pool in their constructor, so the dependency is visible and typed; tests inject a transaction-scoped or in-memory fake without touching globals; shutdown can close the pool after the services that use it, in reverse construction order, so in-flight queries drain instead of erroring; and if the requirement later becomes per-tenant pools (routine in multi-tenant systems with schema or database isolation), it is a Map<TenantId, Pool> in one file rather than a rewrite.
I would also volunteer the sharpening detail: "one pool" is per process, so under cluster with 8 workers the database sees 8 × pool-size connections — a number worth computing against the server's max_connections before it is discovered in production, and a case where the mental model "singleton = one" actively misleads (3.8.6).
The summary sentence: the pool should be a single instance; it should not be a Singleton.
StaffDesign the object-creation story for a multi-tenant SaaS backend: some objects are app-wide (config, pool), some per-tenant (feature-flag client, schema-bound query runner, rate-limit policy), some per-request (unit of work, request logger). The current code uses static singletons and is leaking state across tenants. Lay out lifetimes, wiring, and the failure the old design guaranteed.
The guaranteed failure first, because it frames everything. Static singletons collapse three distinct lifetimes into one. A FlagClient.instance() initialized by whichever tenant's request arrives first serves that tenant's flags to everyone — the isolation bug they are seeing, which is 9.1's hidden global coupling with a compliance price tag. Under cluster it is also inconsistently wrong per worker, so it reproduces intermittently and looks like a caching bug (3.8.6). The design did not merely permit the bug; by hard-coding one lifetime it made the correct lifetime inexpressible.
Redesign as three explicit scopes with three roots. First, app scope — the composition root at startup: Config (validated at birth — 9.4.4's cross-field checks), PgPool, the base Logger, HTTP clients, the metrics registry. Built once, eagerly, in dependency order; injected downward; torn down in reverse. Second, tenant scope — a memoized factory: TenantContextFactory.get(tenantId): TenantContext, where each context bundles the tenant's schema-bound query runner, flag client, and rate-limit policy. Two design points carry the weight here. This is an 9.4.3-shaped family: the members must agree on the tenant, so mixing tenant A's database handle with tenant B's flags becomes unrepresentable rather than merely reviewed for — which is precisely the bug class being fixed.
And memoize the promise per tenant so that concurrent first-requests coalesce instead of racing to build two contexts, and attach an eviction and refresh policy, because an unbounded per-tenant cache is a resource leak wearing a performance costume (3.6.11). Third, request scope — middleware: resolve tenantId from auth, obtain the tenant context, then build the genuinely per-request pieces — the unit of work or transaction, a child logger bound to traceId (3.8.7's AsyncLocalStorage carries it), an idempotency handle. Handlers receive { ctx, uow, log } explicitly; no static reach-back exists to misuse, which is the structural part of the fix.
Patterns named for the design doc: composition root (app), memoized factory / multiton (tenant), an Abstract-Factory-shaped family (tenant consistency), Builder (validated config), and injected lifetimes replacing Singleton throughout. Per-request objects are deliberately not pooled — they are cheap and isolation-critical, and pooling them would reintroduce exactly the sharing being eliminated.
Migration without a freeze: land the three scopes and a Deps type first; convert the leaking flag client first (highest blast radius); make each converted call site delete its getInstance() in the same PR; then ratchet with a lint rule banning getInstance in new code and a shrinking allowlist.
Proof, not hope: tenant isolation becomes testable — construct two tenant contexts, interleave operations, assert zero cross-talk; the old design could not even express that test. Add a production assertion that every query runner's schema matches the request's tenant, alerting on mismatch, because a type system cannot police a bug that reappears through a future shortcut.
The principle for the write-up: multi-tenancy is a lifetime problem wearing a security costume — model the three lifetimes explicitly, give each one a root, make cross-tenant mixing unrepresentable, and isolation stops being policeable behaviour and becomes an impossible state.
Flashcards
FlashSingleton in one line
Conflates lifetime (one exists) with access (reachable anywhere). Keep the lifetime at the composition root; replace the access with injection.
FlashThe four costs
Hidden global coupling · test hostility (shared state, every workaround costs) · conflated decisions that change independently · one per PROCESS, not per system.
FlashComposition root
The one startup file that builds everything once, eagerly, in dependency order and passes it down. No new of volatile classes below it. Shutdown in reverse.
FlashAsync singleton bug
Caching the value races at the await point — two callers, two instances. Cache the PROMISE. Decide whether a rejected promise is retried or sticky.
FlashTwo singletons in one process
Duplicate module copies (CJS + ESM, nested node_modules, separate bundler chunks). The module cache is keyed by resolved path. Last resort: globalThis[Symbol.for(...)].
FlashWhat you usually actually wanted
A multiton — one per tenant/shard/region. Under a composition root that is a Map and a lookup; under a Singleton it is a rewrite.
Scenario Drill
DrillA payments service uses a static InMemoryRateLimiter.getInstance() to enforce 100 requests per second per merchant. It works in staging with one instance and fails in production behind an autoscaling group of 12 pods, where merchants report both being throttled unfairly and exceeding their limits. Explain both symptoms, design the fix, and say what you would keep in-process anyway.
Both symptoms come from the same root, which is why the ticket looks contradictory. The limiter is one instance per process, so with 12 pods there are 12 independent limiters.
Exceeding the limit: a merchant's requests are spread across the pods by the load balancer, and each pod independently allows 100 rps, so the effective ceiling is up to 1,200 rps — the SLA the limiter exists to enforce is violated by a factor of 12, silently, with every component behaving "correctly".
Unfair throttling: the load balancer's distribution is not perfectly even — sticky sessions, keep-alive connections pinning a client to one pod, or a hash-based policy — so a merchant whose traffic concentrates on two pods gets throttled at about 200 rps while a merchant spread evenly gets about 1,200. Add autoscaling and the limit becomes a function of the current replica count, so it changes during traffic spikes — precisely when it matters, and in the wrong direction, because scaling out to handle load simultaneously loosens the limit.
The fix: move the counter to shared state, and make the algorithm atomic there. A Redis-backed token bucket or sliding window, keyed by merchantId, with the check-and-decrement executed as a single atomic operation — a Lua script, or INCR plus EXPIRE in one round trip — because a read-then-write from 12 pods would reintroduce a race at the shared store (10.7.2). Choose the algorithm deliberately: fixed windows are cheapest but allow a 2× burst at the boundary; a sliding-window log is exact but stores every timestamp; token bucket is the usual answer because it bounds both rate and burst with two numbers (9.7.5).
What I would keep in-process anyway — and this is the part that shows judgment. First, a local pre-filter: an in-process limiter set well above the shared limit, catching pathological single-client floods without a network hop, so the shared store is not itself the bottleneck. Second, a fail-open or fail-closed policy for when Redis is unavailable, chosen and documented — for a payments service, fail-open below a global safety ceiling and fail-closed above it is the defensible middle, and stating which you chose is the graded part of the answer. Third, local caching of the decision for very short windows (tens of milliseconds) to cut round trips, accepting a bounded overshoot as an explicit trade.
The design lesson to name explicitly: the limiter was never wrong as code — it was a correct in-process singleton for a system-wide invariant, and the mismatch was invisible in staging precisely because staging ran one replica. Any invariant that must hold across processes needs state that lives outside processes; "singleton" guarantees exactly one per process and nothing more.
The prevention, so it does not recur: make replica count part of the staging environment (never test a distributed limit at replica count 1), emit ratelimit.effective_rps per merchant from the shared store, and alert when observed throughput exceeds the configured limit — the metric that would have caught this on day one instead of via merchant complaints.