---
title: Singleton
source: 'https://academia.sh/en/courses/design-patterns/singleton'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:14+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// singleton/tariffs/standard.mjs — standard tariff definition
export const STANDARD = { name: "standard", baseFee: () => 3900 };
```

```js
// 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.

```js
// instance/tariffs/standard.mjs — standard tariff definition
export const STANDARD = { name: "standard", baseFee: () => 3900 };
```

```js
// 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.

```js
// 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)],
];
```

```js
// 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));
```

```sh
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.

```js
// 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));
```

```sh
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.

```js
// 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));
```

```sh
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.
