← Design patterns
Creation Keyed identity and ownership

Multiton

One per key, within an owner.

A venue’s Console and Remote both adjust Studio’s audio channel. Turning its gain down from one control should change what the other reads, and the Lounge needs its own setting. Keep a channel for each room and return the one already retained; then be precise about the key, the owner, and how long that promise lasts.

TypeScriptGoRust The same owner in each language.

Definition

A Multiton keeps one instance per key inside an owner, creating it on the first lookup and returning that same instance on every later one, so callers that name the same key share its state.

01One per room

Getting Studio again should keep its current setting.

For a single control, a plain channel object is enough. A second control can receive that same object directly: while bootstrap knows which control belongs to which room, it creates each channel once and hands it over.

channels.ts
// Bootstrap knows every pairing, so it creates each channel once and hands it over.
const studio: Channel = { room: 'studio', id: 1, gain: 80 };
const lounge: Channel = { room: 'lounge', id: 2, gain: 80 };
attachControl('console', studio);
attachControl('remote', studio);
attachControl('bar tablet', lounge);

That stops working when rooms are chosen by name at run time: the Remote has a room picker, and a session opens rooms as the evening goes on. Bootstrap can no longer hand every control its channel in advance, so something must resolve a room’s name to its channel. Creating another channel on each lookup would lose the shared identity: Console turns Studio down, and Remote still reads the old setting.

A Multiton keeps one instance for each key and returns that same instance on every later lookup. The familiar presentation uses a static map and a keyed accessor. This lesson makes the owner explicit: the guarantee is one retained channel per room within that owner. Two owners can each have a Studio channel.

02Follow the keyed lookup

The map remembers identity. The channel keeps its setting.

The owner fixes an initial gain once, when it is created. Its get looks the room up: on a miss it creates a channel, stores it and returns it; on a hit it returns the stored channel with its current setting, so getting Studio again never asks for a reset. Its forget removes an entry. This is the whole program the lab runs: the owner, two controls in one mixing session (Mix A), and a second session (Mix B).

channels.ts
type Channel = { room: string; id: number; gain: number };

// The owner: one retained channel per room, for one mixing session.
function createRoomChannels(initialGain: number) {
  const entries = new Map<string, Channel>();
  let created = 0;
  return {
    get(room: string): Channel {
      let channel = entries.get(room);
      if (!channel) {
        created++;
        channel = { room, id: created, gain: initialGain };
        entries.set(room, channel);
      }
      return channel;
    },
    forget(room: string): boolean {
      return entries.delete(room);
    },
  };
}

const mixA = createRoomChannels(80);
const consoleStudio = mixA.get('studio');
const lounge = mixA.get('lounge');
consoleStudio.gain = 30;
const forgotten = mixA.forget('lounge');
const remoteStudio = mixA.get('studio');
const shared = consoleStudio === remoteStudio;
const mixB = createRoomChannels(80);
const otherSession = mixB.get('studio');

const show = (name: string, c: Channel) =>
  console.log(`${name}: ${c.room} #${c.id} gain=${c.gain}`);
show('console', consoleStudio);
show('remote', remoteStudio);
show('lounge', lounge);
show('mix B', otherSession);
console.log(`shared: ${shared}, forgotten: ${forgotten}`);
Who owns what
RoleResponsibilityWhat it handles
Caller → keyChooses the room.Getting that room again does not ask for a reset.
Owner → retained entryReuses or creates.A miss creates and keeps one channel. A hit returns that object with its current state.
Channel → settingKeeps one gain setting.A gain change updates this channel. Other rooms’ channels keep their own.

Opening a mixing session creates one owner. The application resolves a room, obtains its channel, and passes that handle to the Console or Remote control; the controls need only the channel. A test can supply a fresh owner without changing another session’s settings. Keeping the owner for the session preserves sharing; constructing it inside every control would create independent settings again.

What a production owner addsExact keys, checked gain, and locks

The same owner, checked. Lookup accepts exactly studio or lounge, with no trimming, case folding or fallback key, and an unknown key fails before an entry is created; that keeps at most two entries in each owner. Gain is an integer from 0 through 100, checked when the owner is created and on every change, before anything changes; changes go through setGain so the check cannot be skipped. A repeated lookup never takes a default: supplying one at every lookup would invite conflicting configuration for one room.

The channel’s method surface is frozen; its gain lives in a closure and still changes.

channels.ts
export type Room = 'studio' | 'lounge';
export type Channel = Readonly<{
	room: Room;
	id: number;
	setGain(value: number): void;
	gain(): number;
}>;

// Exactly "studio" or "lounge": no trimming, case folding or fallback key.
export function roomKey(raw: string): Room {
	if (raw !== 'studio' && raw !== 'lounge') throw new Error('unknown room: use studio or lounge');
	return raw;
}

function validateGain(value: number) {
	if (!Number.isInteger(value) || value < 0 || value > 100)
		throw new Error('gain must be an integer from 0 to 100');
}

function createChannel(room: Room, id: number, initialGain: number): Channel {
	let gain = initialGain;
	return Object.freeze({
		room,
		id,
		setGain(value: number) {
			validateGain(value);
			gain = value;
		},
		gain: () => gain
	});
}

export function createRoomChannels(initialGain: number) {
	validateGain(initialGain);
	const entries = new Map<Room, Channel>();
	let created = 0;
	return Object.freeze({
		get(raw: string): Channel {
			const key = roomKey(raw);
			let channel = entries.get(key);
			if (!channel) {
				// Lookup, creation and storing are synchronous: no await or callback between them.
				channel = createChannel(key, ++created, initialGain);
				entries.set(key, channel);
			}
			return channel;
		},
		// Removes the room's entry, and says whether there was one.
		forget(raw: string): boolean {
			return entries.delete(roomKey(raw));
		},
		size: () => entries.size,
		creations: () => created
	});
}
export type RoomChannels = ReturnType<typeof createRoomChannels>;

These channels are small in-memory objects, so creating one while the owner’s lock is held makes the first lookup and the storing one step: two callers that miss at once cannot both create a channel. Slow I/O, or a constructor that calls back into the owner, would need a different design. For asynchronous TypeScript creation, an await between checking the map and storing a result lets another caller see the same miss; retaining the in-flight promise can coordinate callers, but then rejection, retry, cancellation and removal of that exact attempt need explicit rules.

03Predict what is held

Follow the held reference, not just the key.

Experiment

Two controls, two sessions

channels.ts
// Line 27 becomes: the session forgets Studio instead of the Lounge.
const forgotten = mixA.forget('studio');
Suppose the session forgets Studio instead of the Lounge, as below. Console set Studio’s gain to 30 just before; Remote looks Studio up just after. What do the console and remote lines print?
Reading the TypeScriptReferences, ===, and one thread

A Map keeps a reference to each channel object, and get returns that same reference, so === compares identity, not contents. Setting gain through one reference changes the one object every holder sees.

The miss path has no await or callback between checking and storing, so no other code in this JavaScript thread can run in between and create a second channel. Another worker or server process has its own memory, and its own map.

In the production owner, Object.freeze fixes each channel’s method surface; the gain variable in its closure is deliberately mutable. This is shared state, not an immutable value.

04Meet it in your language

Named access is familiar. Its contract still matters.

You probably already follow this rule if you have written a service worker: put the version in the cache name, and delete every other cache when the new worker activates. SvelteKit’s service worker example does both, and the Service Worker spec explains that caches “do not disappear just because the service worker script is updated,” so “authors should version their caches by name.” The reason is this lesson’s lookup, run by the browser.

caches.open(name) is a keyed accessor. The browser keeps one map from names to caches for an origin, shared by every tab and worker on it. A miss creates and retains an empty cache; a hit returns the cache with whatever it already holds. Each call gives you a new Cache object, but objects opened with one name reach the same entries. The origin is the owner, and the name is the whole key. With a fixed name, a new worker installs while the old one still controls your open tabs and writes the new deploy’s files into the cache the old worker is serving: in Chromium 153 (September 2026), a tab on the old version asked for /app.js and got the new deploy’s file.

service-worker.ts
/// <reference lib="webworker" />
// A SvelteKit service worker (src/service-worker.ts), cut to the cache.
import { build, version } from '$service-worker';

declare const self: ServiceWorkerGlobalScope;

// Before: const CACHE = 'cache'; every deploy opened the same name, so a
// new worker's install wrote into the cache the old worker was serving.
// After: the deploy is part of the key.
const CACHE = `cache-${version}`;

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(build)));
});

// Forget every other deploy's cache. A tab still holding one keeps using it.
self.addEventListener('activate', (event) => {
  const old = (key: string) => key !== CACHE && caches.delete(key);
  event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map(old))));
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE).then(async (cache) =>
      (await cache.match(event.request)) ?? fetch(event.request)
    )
  );
});

With the version in the name, that tab kept the old file until every tab had closed. Deleting a cache is forget: the spec says the currently referenced Cache objects “should remain functional,” and in Chromium a tab holding the old one could still read and write it while the next caches.open of that name returned an empty cache. Firebase’s getApp(name) is the same shape for app instances; its deleteApp makes the app unusable and frees its resources, a larger contract than detaching an entry.

05Choose the whole key

“Same key” must mean “should share.”

Our two room names are unique only within one venue. A real system may have another identity dimension. Adding a mutex would not fix callers being grouped under the wrong key, and a map cannot repair an incomplete one: “same key” must mean “should share.”

What a new requirement asks you to decide
ChangeDecision it requires
Open a connection asynchronouslyCoordinate in-flight initialization, publish once, and define failure and retry behavior.
Accept arbitrary room keysDecide admission bounds and lifecycle. Rejecting a new key can be safer than evicting a channel still held by a control.
Change configuration for an existing keyReject a mismatch, version the identity, or perform coordinated replacement. Do not silently let lookup order decide.
Run an isolated testCreate a fresh owner and inject its channel; do not clear a shared global used by other tests.

For a fixed handful of known objects, bootstrap can create and inject them directly. A keyed accessor earns its place when identity lookup and retention are responsibilities callers should not repeat. Keeping the owner forever makes its lifetime an application policy; neither choice follows from using a map.

Its neighbours: Singleton is the one-slot case of the same idea. Registry stores what a contributor already built and hands it back without creating anything; here the owner creates on a miss, and what it stores is a stateful channel whose identity and current gain matter. Factory centralizes creation and may return a fresh object each time; retention is the added promise. Flyweight uses the same code shape for frozen data many places read, while a channel is shared mutable state two controls change. Object pool lends an instance to one caller at a time; here both controls hold one channel at once. And a memo table keyed by room could be rebuilt without anyone noticing; this one could not, because Console is still holding the old channel.

When entries own sockets, timers, or other resourcesMemory ownership and operational lifetime are separate

Deleting a map entry removes one reference. Whether and when cleanup happens depends on the resource and on the ownership design. Another live reference can keep the allocation alive, and cleanup tied to dropping the last reference waits for it; that does not by itself define safe cancellation or shutdown.

A resource owner may need to stop new work, wait for active users, close the resource, and only then permit a replacement. Calling close can also make an object unusable while references to it remain.

06Make the call

Which callers should share this exact instance?

Two venues both have a room called studio. Their channel settings must stay independent, while Console and Remote within each venue must still share. Which change keeps that rule?
A practice-room app has exactly two channels, created when it starts. Each control is wired to one of them at startup, and nothing looks a channel up by name later. What should it use?
Now somewhere else: an editor extension shows a Git status panel and a branch panel, and both must read one status watcher per repository, so a refresh in one is seen by the other. What should the extension key its watchers by?

Before you open the answer below, explain the rule without saying “Multiton”: within this owner, these keys select these shared instances, for this lifetime. Then describe what happens to a caller that holds an old reference after its entry is removed.

One way to say itKey, owner, lifetime, and the old handle

Within one owner, a key selects one shared instance: the first lookup creates and retains it, and every later lookup returns it with its current state, so callers that should share name the same key and get the same object. The key must include everything that decides sharing (the venue as well as the room, a repository’s real root rather than the path it was opened with), and the owner must live exactly as long as the sharing should: a mixing session, an account’s workspace. Removing an entry ends only the owner’s promise. A caller that still holds the old instance keeps using it, and the next lookup creates another, so decide what ends an entry’s life (its last holder leaves, an explicit close) rather than evicting one a caller may still hold. When bootstrap already knows every pairing, pass the objects directly: a keyed owner earns its place when callers choose by name at run time.

Practice in the lab

A multiplayer game server hosts chess and checkers. Players join a game by typing a four-letter code, and everyone who joins the same game with the same code must play in one room: a move one player makes is the move the other sees. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • MULTITON-101 (easy) Two players, one code, two different games
  • MULTITON-102 (medium) Rooms that never close fill the server Pro
  • MULTITON-103 (hard) Review: after a rematch, half the table plays the old game Pro