Definition
A registry records implementations under names while an application starts up, supplied by whoever owns them, and lets other code find one by name later, so the code that looks up a name never has to know who contributed it.
01Give a name an owner
Someone else needs to add a shortcode.
A page renderer meets tags such as [note]Back up first[/note] in a page and replaces
each one with an HTML fragment. With two shortcodes shipped by the theme, a switch in the renderer
is easy to read: match the tag name, pass the inner text to a function, splice in the result. The
editor’s tag picker keeps its own list of the same two names.
// The renderer knows every shortcode the theme ships…
function render(tag: string, text: string): string {
switch (tag) {
case 'note':
return `<aside>${text}</aside>`;
case 'code':
return `<pre>${text}</pre>`;
default:
return `[unknown shortcode: ${tag}]`;
}
}
// …and the editor's tag picker keeps its own list.
const picker = ['code', 'note']; Later, a plugin wants to add [kbd] for keyboard keys. The renderer needs to find
it, and the editor’s tag picker needs to list it. You could keep adding branches to the switch
and editing the picker separately, but the renderer would then have to know every plugin
anyone might enable. As shortcodes gain different owners, it becomes useful to let setup
collect the available implementations in one place.
A registry records entries under names and lets other code find them later. Here, each entry is an existing shortcode function. The theme and plugins supply the functions; setup registers them; the renderer looks one up by the tag name it just read. This is a scoped plugin registry: setup owns one instance and passes it to the renderer that needs discovery.
That separation gives you three decisions to name: what can register, what happens when a name is already taken, and what a caller receives when a name is missing. A map stores the associations; the registry defines the lookup contract.
How does this relate to Fowler’s Registry?The broader service-location form
In Martin Fowler’s Registry pattern (Patterns of Enterprise Application Architecture, 2002), a well-known object provides access to common objects and services, often appearing global to its callers. That broader form explains the name’s connection to service location. We’ll keep the lookup responsibility while making our instance and dependencies visible.
02Register, resolve, invoke
Store the function. Return the function. Then call it.
The registry stores a function under a name, returns that function when asked, and leaves the
calling to whoever asked. Registration does not create a shortcode, and lookup does not run one.
A taken name is refused, a missing one fails, and once setup is over seal closes
registration. The renderer knows none of the shortcodes: it receives the registry and a tag.
This is the whole program the lab runs: the registry, what a theme and a keyboard plugin
contribute, a second theme that also ships a note, the tag picker, and the renderer
meeting three tags.
type Shortcode = (text: string) => string;
// The registry: shortcodes by name, filled at start-up, then sealed.
class ShortcodeRegistry {
private entries = new Map<string, Shortcode>();
private sealed = false;
register(name: string, shortcode: Shortcode): void {
if (this.sealed) throw new Error(`registration is closed: ${name}`);
if (this.entries.has(name)) throw new Error(`already registered: ${name}`);
this.entries.set(name, shortcode);
}
resolve(name: string): Shortcode {
const shortcode = this.entries.get(name);
if (!shortcode) throw new Error(`unknown shortcode: ${name}`);
return shortcode;
}
names(): string[] {
return [...this.entries.keys()].sort();
}
seal(): void {
this.sealed = true;
}
}
// What the theme and a plugin contribute. The renderer knows none of them.
function installTheme(registry: ShortcodeRegistry) {
registry.register('note', (text) => `<aside>${text}</aside>`);
registry.register('code', (text) => `<pre>${text}</pre>`);
}
function installKeyboardPlugin(registry: ShortcodeRegistry) {
registry.register('kbd', (text) => `<kbd>${text}</kbd>`);
}
// The page renderer: one tag's name and inner text in, a fragment out.
function render(registry: ShortcodeRegistry, tag: string, text: string) {
try {
return registry.resolve(tag)(text);
} catch (error) {
return `[${(error as Error).message}]`;
}
}
// Bootstrap: every contributor registers, then setup closes.
const registry = new ShortcodeRegistry();
installTheme(registry);
// A second theme, also enabled, ships its own note.
let clash = 'none';
try {
registry.register('note', (text) => `<div class="callout">${text}</div>`);
} catch (error) {
clash = (error as Error).message;
}
installKeyboardPlugin(registry);
registry.seal();
const picker = registry.names();
const noteHtml = render(registry, 'note', 'Back up first');
const kbdHtml = render(registry, 'kbd', 'Ctrl');
const missing = render(registry, 'quote', 'Hi');
console.log(`picker: ${picker.join(', ')}`);
console.log(`note: ${noteHtml}`);
console.log(`kbd: ${kbdHtml}`);
console.log(`quote: ${missing}`);
console.log(`second note: ${clash}`); | Role | Who | What it handles |
|---|---|---|
| Theme or plugin → contributes | Shortcode module | Provides a shortcode’s name and function. Owns the fragment’s markup. |
| Bootstrap → registers | The registry | Accepts unique names, refuses a taken one, lists entries, and closes setup. |
| Renderer → resolves | Page renderer | Owns the tag name it read, the inner text, the call, and what the page shows when lookup fails. |
A new [kbd] shortcode adds a function and a registration in the plugin’s setup. The
renderer’s lookup code stays the same. Bootstrap still needs to know which plugins to load; a
registry does not discover code that has never been loaded.
03Predict what each reader sees
Two readers, one registry. What does each see?
Experiment
A theme, a plugin and a renderer
// Lines 57–59 become: the picker reads the names before the plugin registers.
const picker = registry.names();
installKeyboardPlugin(registry);
registry.seal(); What a production registry addsOne spelling, checked contributions, no early readers
The same registry, checked. A name is a lowercase ASCII letter followed by lowercase letters,
digits or hyphens, and spelling is exact: Note and note are refused,
not corrected. A failed registration changes nothing. The shortcodes escape their text.
And nothing can be looked up while setup is open: resolve and names fail with “registry is still open” until seal runs, so the experiment’s picker fails loudly instead of quietly missing a
plugin. A registry whose contributors look each other up during setup would need a different
rule, such as a separate setup-time lookup.
export type Shortcode = (text: string) => string;
// A name is a lowercase letter, then lowercase letters, digits or hyphens.
// Spelling is exact: "Note" and " note" are refused, not corrected.
const validName = (name: string) => /^[a-z][a-z0-9-]*$/.test(name);
export class ShortcodeRegistry {
#entries = new Map<string, Shortcode>();
#sealed = false;
register(name: string, shortcode: Shortcode): void {
if (this.#sealed) throw new Error(`registration is closed: ${name}`);
if (!validName(name)) throw new Error(`invalid shortcode name: ${name}`);
if (typeof shortcode !== 'function') throw new Error(`shortcode must be a function: ${name}`);
if (this.#entries.has(name)) throw new Error(`already registered: ${name}`);
this.#entries.set(name, shortcode); // only after every check passed
}
resolve(name: string): Shortcode {
this.#ready();
const shortcode = this.#entries.get(name);
if (!shortcode) throw new Error(`unknown shortcode: ${name}`);
return shortcode;
}
names(): string[] {
this.#ready();
return [...this.#entries.keys()].sort();
}
/** Closes setup. Calling it again changes nothing. */
seal(): void {
this.#sealed = true;
}
// A reader during setup would see only the contributors that came first.
#ready() {
if (!this.#sealed) throw new Error('registry is still open');
}
}
const escapeHtml = (text: string) =>
text
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
export const note: Shortcode = (text) => `<aside>${escapeHtml(text)}</aside>`;
export const code: Shortcode = (text) => `<pre>${escapeHtml(text)}</pre>`;
export const kbd: Shortcode = (text) => `<kbd>${escapeHtml(text)}</kbd>`; Reading the TypeScriptMap, private fields, function values
Map<string, Shortcode> maps names to callable values. A method can return a
function just as it can return a number: registry.resolve(tag) returns the shortcode,
and registry.resolve(tag)(text) calls it. Returning a function preserves its identity
and anything it captured.
get returns undefined for a missing name, so the registry checks for it.
A Map also treats constructor as an ordinary key, without the inherited
properties of a plain object.
The small program’s private fields are a type-checker rule. The production
registry uses #entries and #sealed, which are private at run time too,
and checks that a JavaScript caller really passed a function.
04Meet it in your language
Your platform already keeps registries.
If you have used a web component, you have used one. customElements is the page’s CustomElementRegistry: define records a class under a tag name, get looks one up, and the browser uses that entry whenever the tag appears. It refuses
a taken name and never replaces a definition. That matters when you ship a widget onto pages you
don’t control: a theme and an app can both include your script, so it may run twice.
// A reviews widget's script. Shops add it to their pages, and a theme and an
// app can both include it, so it may run twice on one page.
class StoreReviews extends HTMLElement {
connectedCallback() {
this.textContent = `Reviews for product ${this.getAttribute('product')}`;
}
}
// Before: the second copy's define throws NotSupportedError, because the
// page's registry already has the name and never replaces an entry.
function defineBefore() {
customElements.define('store-reviews', StoreReviews);
}
// After: the first copy keeps the name and later copies leave it alone. If a
// newer version must win, put the version in the name: store-reviews-v3.
function define() {
if (!customElements.get('store-reviews')) {
customElements.define('store-reviews', StoreReviews);
}
} In Chromium 153 (checked for this lesson on 17 October 2026), the second copy’s define threw NotSupportedError: … the name "store-reviews" has already been used with this registry,
and get still returned the first class. The guard keeps the first copy’s definition,
which is fine when any copy will do; if a newer version has to win, the version belongs in the
tag name, because the registry’s interface (define, get, getName, initialize, upgrade, whenDefined) has
nothing that removes or replaces a definition. Lookup timing is the other decision: whenDefined(name) waits for a definition instead of failing, so a name nobody ever
defines leaves it pending, and code waiting on an optional widget needs a timeout.
05Choose scope and lifetime
Does the caller need discovery, or already know its collaborator?
I’d keep a fixed map or switch for two shortcodes maintained together. A registry earns its extra methods when separate features contribute named implementations and consumers need one shared policy for accepting and finding them.
| The situation | A useful starting point | What changes the choice |
|---|---|---|
| A small, closed set of shortcodes | A fixed map or switch. | Separate contributors need to extend the set without editing consumers. |
| A caller already knows the shortcode | Pass that shortcode directly. | The caller receives a name at run time and must resolve it. |
| Themes and plugins contribute named shortcodes | A scoped registry with explicit collision rules. | Adding run-time removal or replacement requires ownership and lifetime rules too. |
| Each request needs a new configured instance | A factory, possibly stored in a registry. | Lookup and creation become separate steps; decide which one can fail and who owns the result. |
The wrapper is useful because it enforces an agreement, not because a map needs a more impressive name. If the agreement is already obvious at one construction site, keep that simpler code.
Its neighbours: Strategy makes a behaviour replaceable; a registry finds a named behaviour for the caller, and is often how a strategy is chosen by name. Factory makes something new; a registry may hold factories when each lookup needs a fresh instance. Dependency injection describes how a collaborator is supplied; a registry describes discovery by name, and the two coexist.
The registry becomes a service locatorHidden dependencies
A global services.get('mailer') inside an unrelated operation makes that dependency
hard to see from the operation’s parameters. It also asks every test to prepare the right global
environment. A registry can be used that way; its lookup mechanism does not prevent it.
Keep a narrow registry where run-time names are a real input. After resolving a known collaborator, pass it to the code that uses it.
Plugin loading becomes concurrent or reloadablePublication and removal
Asynchronous module loading can make registration order depend on timing: a plugin whose module resolves late registers after a faster one. Await every contributor, then seal, then hand the registry to readers.
For reloadable plugins, consider explicit replacement or unregister handles tied to a registration’s owner. An old cleanup callback should not delete a newer registration that reused the same name.
A name starts pretending to be a complete interfaceCapabilities and compatibility
Every shortcode here accepts inner text and returns a fragment synchronously. A plugin needing the page’s document tree, attributes on the tag, or asynchronous work has different requirements. A matching registration name does not make those interfaces compatible.
Names also become an API once they appear in saved pages. Renaming note may need an
alias or a migration. Make that choice explicit; silently lowercasing or falling back to another
shortcode can hide an unsupported tag.
06Make the call
Who owns a name, and when does the lookup start?
Before you open the answer below, explain the boundary without saying “registry”: who supplies the entries, who owns the names and their duplicate rule, when the lookup is complete, and what the renderer can stop knowing about a shortcode.
One way to say itContributors, names, setup, lookup
Separate contributors (a theme, its plugins) hand implementations they already built to one object, each under a name, while the application starts. That object owns the names: their spelling, that each is taken once, and what a missing one means. Lookup returns what was registered and creates nothing, so the renderer only needs the tag it read; it stops knowing which shortcodes exist or who wrote them. The set is complete only when setup is, so close registration, then hand the registry to its readers, and make any reader that keeps a copy (a picker’s names) take it after that point. Keep the registry scoped to the application or test that built it rather than global, and use it only where a name really arrives at run time; a closed set maintained together is a switch, and a known collaborator is passed directly.
Practice in the lab
A spreadsheet evaluates formulas such as =SUM(1, 2) by finding the function a name stands for. The built-ins ship with it; add-ins the user enables bring more (a units add-in, an inventory add-in), and the formula engine must find them without knowing any of them, just as the lesson’s renderer finds a plugin’s shortcode. Each level starts from the previous level’s finished code, so you can begin at any of them.
- REGISTRY-101 (easy) An add-in’s function says #NAME?
- REGISTRY-102 (medium) Half an add-in, and a picker that cannot say whose Pro
- REGISTRY-103 (hard) Review: one workbook’s DISCOUNT shows up in another Pro