Lesson 05 / 30
Singleton
Measuring, in terms of testability, holding a single instance as global state: applying the same four tests to a module-level single-instance registry and an instance-producing registry, counting the failed test count, whether the result changes when the test order changes, and the number of names the reset mechanism adds to the exposed surface.
Contents
Four creational mechanisms arranged where an object comes from; none asked how many of it there are. The library has objects meant to exist as a single instance: the tariff registry leads that list, because two separate registries could bind two different tariffs to the same name, and the same shipment could get two different fees.
The singleton writes this requirement into the object itself: the type builds its own instance, guarantees it holds only one, and gives global access to it. In its classic form, the constructor is closed off from the outside and access is given through a single class method. In a language with a module system, this is already done by the module itself: a module’s body runs once per process, and module-level state is the same for every importer. This lesson counts the pattern’s cost with a single measure: testability.
A Single Instance at Module Level
In the first version, the registry is a module. State sits at module level, gets built on the first import, and stays the same for the life of the process.
// singleton/tariffs/standard.mjs — standard tariff definition export const STANDARD = { name: "standard", baseFee: () => 3900 };
// singleton/registry.mjs — single instance at module level; state is built on first import import { STANDARD } from "./tariffs/standard.mjs"; const TARIFFS = new Map(); export function register(tariffObject) { TARIFFS.set(tariffObject.name, tariffObject); } export function tariff(name) { const t = TARIFFS.get(name); if (t === undefined) throw new RangeError(`unknown tariff: ${name}`); return t; } export const names = () => [...TARIFFS.keys()]; export function reset() { TARIFFS.clear(); register(STANDARD); } reset();
The last line is what decides the pattern: state is built while the module loads, so no
importing module can choose the moment it gets built. TARIFFS is not exported, but every
module that calls register changes it.
Building the Same Registry as an Instance
In the second version, the registry is a type, and state sits in the instance’s private field. The module does not decide how many instances get built; the caller does.
// instance/tariffs/standard.mjs — standard tariff definition export const STANDARD = { name: "standard", baseFee: () => 3900 };
// instance/registry.mjs — every call produces an independent registry instance import { STANDARD } from "./tariffs/standard.mjs"; export class TariffRegistry { #tariffs = new Map(); register(tariffObject) { this.#tariffs.set(tariffObject.name, tariffObject); return this; } tariff(name) { const t = this.#tariffs.get(name); if (t === undefined) throw new RangeError(`unknown tariff: ${name}`); return t; } names() { return [...this.#tariffs.keys()]; } } export const newRegistry = () => new TariffRegistry().register(STANDARD);
The requirement that the application run on one registry has not gone away; it has moved. The
composition root calls newRegistry once and hands the resulting instance to the modules that
will use it. In other words, the “single instance” decision has moved from the object itself to
the composition root.
Applying the Same Tests to Both Versions
The two versions export the same method names: register, tariff, names. This lets the same
test set apply to both. The tests take a registry getter as a parameter; in the singleton
version, the getter returns the same module on every call, and in the instance version, a new
instance on every call.
// tests.mjs — four tests; each one takes a registry getter import assert from "node:assert/strict"; export const TESTS = [ ["loaded tariff count", (getRegistry) => assert.equal(getRegistry().names().length, 1)], ["new tariff gets registered", (getRegistry) => { const r = getRegistry(); r.register({ name: "discounted", baseFee: () => 3200 }); assert.equal(r.tariff("discounted").baseFee(), 3200); }], ["registry holds only loaded tariffs", (getRegistry) => assert.equal(getRegistry().names().length, 1)], ["unknown tariff throws", (getRegistry) => assert.throws(() => getRegistry().tariff("discounted"), RangeError)], ];
// forward.test.mjs — the four tests applied to both versions in the order they are declared import test from "node:test"; import { TESTS } from "./tests.mjs"; import * as singletonRegistry from "./singleton/registry.mjs"; import { newRegistry } from "./instance/registry.mjs"; const VERSIONS = [["singleton", () => singletonRegistry], ["instance", () => newRegistry()]]; for (const [version, getRegistry] of VERSIONS) for (const [name, check] of TESTS) test(`${version}: ${name}`, () => check(getRegistry));
node --test --test-reporter=tap forward.test.mjs 2>&1 | grep -E '^(not ok|# (pass|fail))'
not ok 3 - singleton: registry holds only loaded tariffs not ok 4 - singleton: unknown tariff throws # pass 6 # fail 2
Two of the eight tests fail, both in the singleton version; all four passed in the instance
version. Neither failed test carries a defect of its own: the third expects the registry to
hold only loaded tariffs, the fourth expects an unknown tariff to throw. What breaks both is
that the second test ran first — it wrote a discounted tariff into the registry, and that
write stayed for the rest of the process.
This is exactly the common coupling defined in the Types of Coupling lesson: one unit’s behavior changed by a unit that never imported it. The third test does not import the second, does not know its name, does not choose its order; its result still depends on it.
Reversing the Order
That the bond is genuinely order-dependent is checked by running the same four tests in reverse order.
// reverse.test.mjs — the same four tests, run in reverse order import test from "node:test"; import { TESTS } from "./tests.mjs"; import * as singletonRegistry from "./singleton/registry.mjs"; import { newRegistry } from "./instance/registry.mjs"; const VERSIONS = [["singleton", () => singletonRegistry], ["instance", () => newRegistry()]]; for (const [version, getRegistry] of VERSIONS) for (const [name, check] of [...TESTS].reverse()) test(`${version}: ${name}`, () => check(getRegistry));
node --test --test-reporter=tap reverse.test.mjs 2>&1 | grep -E '^(not ok|# (pass|fail))'
not ok 4 - singleton: loaded tariff count # pass 7 # fail 1
The failed count fell from 2 to 1, and which test failed changed: in forward order, the third and fourth; in reverse order, the first. Nothing about the test bodies, the registry modules, or the tariff definition was touched; only order changed. This is exactly the flaky test defined in the Frontend Quality course, and its count is 2 in the singleton version and 0 in the instance version, which passed both runs because each test builds its own registry and drops it when it finishes.
The Cost of Resetting
The singleton version can be made testable; doing so takes a method that resets state and a step before every test that calls it.
// resetting.test.mjs — the singleton version resets before every test import test, { beforeEach } from "node:test"; import { TESTS } from "./tests.mjs"; import * as singletonRegistry from "./singleton/registry.mjs"; beforeEach(() => singletonRegistry.reset()); for (const [name, check] of TESTS) test(`singleton: ${name}`, () => check(() => singletonRegistry));
node --test --test-reporter=tap resetting.test.mjs 2>&1 | grep -E '^(not ok|# (pass|fail))' for d in singleton/registry.mjs instance/registry.mjs; do echo "$d: $(grep -c 'export ' $d) exposed names" done
# pass 4 # fail 0 singleton/registry.mjs: 4 exposed names instance/registry.mjs: 2 exposed names
All four pass; the fix works. The cost is in two numbers. First, the exposed name count: the
singleton version gives four names, the instance version two. The extra reset name is never
called in production; it exists only for tests. Under the principle of least visibility from the
Clean Code course, this name is dead surface, and being callable from production code as well
opens a new failure mode.
The second cost is not the number but keeping it current. The reset list is maintained by hand:
add a second global field to the registry and reset must be edited too, or tests turn
order-dependent again, and that bond becomes visible only once order changes. The instance
version has no such list, because there is no state left to reset once a new instance exists.
When to Apply a Singleton
The measures do not ban the pattern outright; they narrow where it applies. A singleton does not break the measures as long as the state it holds does not change during the process: a fixed table, a configuration read once, or an immutable mapping can sit at module level, since no change carries between tests. What breaks the measures is not singleness but the single instance being mutable.
The registry does not meet that criterion: register changes state. What can stay a singleton
in the library is the tariff objects themselves — frozen, fixed-behavior objects — not the
registry.
Summary
- The singleton writes the single instance into the object’s own responsibility and gives it global access; in a language with a module system, module-level state already provides this.
- Applying the same four tests to both versions, 2 tests failed in the singleton version, and all 4 of 4 passed in the instance version.
- The failure’s cause was not the tests themselves but their order: run in reverse, the failed count fell from 2 to 1, and which test failed changed.
- The singleton version became testable with a reset method, and all four tests passed; the cost was the exposed name count rising from 2 to 4 and a reset list maintained by hand.
- A singleton does not break the measures once the state it holds is not mutable; what breaks them is not singleness but the single instance being mutable.
Next Step
The instance version passed its tests, but it left one question open: where does the module
using the registry get that instance from. If newRegistry is called inside the module itself,
every module builds its own registry, and the single-instance requirement is violated; if the
module imports the singleton version, global state comes back. The next lesson takes up this
question as moving the creation responsibility: it compares the fee module’s direct dependency
count and import closure size between a version that builds its own dependency and a version
that takes it from the outside.
To keep your progress and take notes, Log in
My notes
Log in to take notes.