← Design patterns
Application Named lookup

Registry

Give a name somewhere to lead.

A page says [note]. The theme that ships the note shortcode, the plugin that adds [kbd], and the renderer that meets the tag are separately owned. A registry connects those pieces. The interesting part is deciding who gets to register what, and for how long.

TypeScriptGoSame behavior, across the comparison.

01 / The idea

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.

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

If you have used a web component, you have used one: customElements.define('shortcode-kbd', ShortcodeKbd) records a class under a tag name, and the browser looks it up whenever that tag appears.

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’s contract explains how the application may use them.

Which meaning of Registry are we using?Scoped plugins and shared services

This lesson covers a scoped plugin registry: a directory for one kind of collaborator, passed explicitly to the part of the app that needs discovery. It does not need a single global instance.

In Martin Fowler’s Registry pattern, 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.

02 / See the shape

Store the function. Return the function. Then call it.

The basic form is deliberately small. Put noteBlock under the name note, retrieve it, and call it with “Hello.” Registration does not create a shortcode, and lookup does not run one.

In the wild adds the application’s rules: names have a defined spelling, duplicates fail, unknown names fail, and setup can close registration. The underlying map stays private so callers use those rules.

The shortcode contract is a synchronous string-to-string function: inner text in, HTML fragment out. Every sample escapes the text, then wrap it in an aside, a pre block, or a kbd element. The registry does not know what any of those fragments look like.

One map, one name, one existing function. Registration stores the note shortcode; lookup returns it; the renderer calls it. This small form permits replacement and leaves lifecycle decisions to its caller.

TypeScriptReading
shortcodes.ts
export type Shortcode = (text: string) => string;

// These sample shortcodes return HTML fragments, not complete documents.
function escapeText(text: string): string {
	return text
		.replace(/&/g, '&')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;')
		.replace(/'/g, '&#39;');
}
export const noteBlock: Shortcode = (text) => `<aside class="note">${escapeText(text)}</aside>`;

export function basicExample(): string | undefined {
	const entries = new Map<string, Shortcode>();
	entries.set('note', noteBlock); // register an existing function
	const shortcode = entries.get('note'); // lookup does not call it
	return shortcode?.('Hello'); // the renderer invokes the function
}
// A bare Map permits overwriting. The next form makes that decision explicit.
GoAlongside
shortcodes.go
type Shortcode func(text string) string

// These sample shortcodes return HTML fragments, not complete documents.
var htmlEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;")

func escapeText(text string) string { return htmlEscaper.Replace(text) }

func NoteBlock(text string) string { return `<aside class="note">` + escapeText(text) + "</aside>" }

func BasicExample() (string, bool) {
	entries := map[string]Shortcode{}
	entries["note"] = NoteBlock      // register an existing function
	shortcode, ok := entries["note"] // lookup does not call it
	if !ok {
		return "", false
	}
	return shortcode("Hello"), true // the renderer invokes the function
}

// A bare map permits overwriting. The next form makes that decision explicit.
The behavior every version promisesRegistration, lookup, discovery, sealing

A name starts with a lowercase ASCII letter and contains only lowercase letters, digits, and hyphens. Spelling is exact: NOTE and note are rejected, not normalized. These are our application’s conventions.

Registration checks whether setup is closed, whether the name is valid, whether the supplied value is callable where the language permits a missing value, and whether the name is taken. A failed registration changes nothing. The same function may be registered under two different names.

Lookup validates the name, then returns the registered function or a failure. names() returns a fresh list of names in ascending ASCII order, independent of registration order. An empty registry is valid. Calling seal() repeatedly is harmless; once sealed, lookup still works but new registrations fail.

Sealing is an extra application rule for a fixed setup phase. Registration and lookup are the core idea.

Reading the TypeScriptMap, private fields, function values

Map<string, Shortcode> maps names to callable values. The #entries and #sealed fields are private at runtime. A method can return a function just as it can return a number; resolve(name) returns the shortcode and shortcode(text) invokes it.

get can return undefined, so the registry checks for a missing entry. A shortcode that returns the empty string still exists. The map also treats constructor as an ordinary key, without the inherited properties of a plain object.

Returning a function preserves its identity and any captured state. The names array is copied, but shortcodes are not cloned. Declared string inputs are assumed rather than validated at runtime.

Reading the GoComma-ok lookup, pointers, returned errors

Shortcode names a function type. A map lookup can return both the value and an exists boolean; that separates a missing key from the value’s zero value. We reject a nil shortcode during registration.

The registry’s zero value is usable: its first successful registration allocates the map. Methods use a pointer receiver. Pass *ShortcodeRegistry around and do not copy the populated struct: a struct copy would share the map while copying the sealed flag separately.

Registration and lookup return errors to the caller. Returning a shortcode copies the function value, which can still refer to shared captured state. Returning the names creates an independent slice.

03 / Follow the registration

Does a second registration change what note means?

The directory already maps note to the theme’s aside. Predict what happens when the plugin’s keyboard-key shortcode tries to claim it, then press Register shortcode. Check both the feedback and the rendered fragment.

Next, register the keyboard-key shortcode as kbd and look up that name. Finally, seal registration and try adding another name. Which operations still work?

Who owns the name note?

Try registering the plugin’s keyboard-key shortcode under a name the theme’s note aside already owns. Then try a new name.

Setup registers namesRegistration openRenderer resolves & invokes
Contribute a shortcode

Registered names

code
Code block (theme)
note
Note aside (theme)

Names are sorted for discovery. They do not set registration priority.

The page renderer

Try note, a name you registered, or an unknown name such as quote.

Returned function → renderer calls it

<aside class="note">A &amp; B</aside>

The function registered under this name owns the fragment’s markup.

Runs the exact TypeScript registry and shortcodes shown in the lesson. Output is displayed as source text.

The input name and implementation are a proposed registration until you submit them. A rejected proposal leaves the current directory intact. The renderer keeps calling whichever function actually owns the tag name it read.

04 / Try a decision

A collision is an ownership question.

A map assignment can make a collision disappear from the code. Follow what each theme would believe afterward.

Two enabled themes both register note. Which policy makes this setup conflict visible?

The application promises one owner per name. One theme renders a note as an aside; the other renders it as a callout box. Neither intentionally replaces the other.

05 / Give it a real job

Setup assembles the directory. The renderer uses it.

At startup, bootstrap creates one registry and asks the active theme and the enabled plugins to contribute their shortcodes. It handles registration errors, closes setup, and then passes the registry to the page renderer. The editor’s tag picker gets its options from names(); each tag the renderer meets resolves its name and invokes the returned function on the inner text.

01 / Theme or plugin → contributes

Shortcode module

Provides a shortcode’s name and implementation. Owns the fragment’s markup and any shortcode-specific failure.

02 / Setup → registers

Scoped registry

Accepts unique names, reports collisions, lists entries, and preserves them after setup closes.

03 / Renderer → resolves

Page renderer

Owns the tag name it read, the inner text, invocation, 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, and the discovered name list includes kbd. Bootstrap still needs to know which plugins to load; a registry does not discover code that has never been loaded.

One failed registration preserves existing entries. It does not roll back earlier successful registrations. The complete example stops setup on failure and never publishes the partly configured instance. A different application might disable an optional plugin and report why.

Each test can create its own registry. There are no import-time registrations or shared “current theme” variables in this example. That makes the setup visible and keeps one test’s entries from becoming another test’s starting state.

Build UIs?The browser keeps a registry of custom element names. Shipping a widget onto pages you don’t control makes its duplicate rule your decision.

Where it already is in your components

You may already know this rule if you have put a web component in a Vite app: save the file that defines it, and instead of a hot update the console shows NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry': the name "shortcode-kbd" has already been used with this registry. A reload makes it go away. The reason is a registry the browser runs for you.

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. Its rules are close to ours. The HTML Standard requires a name that starts with a lowercase ASCII letter, has no uppercase letters, and contains a hyphen, so define('kbd', …) throws a SyntaxError. A name that is already defined throws NotSupportedError and keeps its first class, and the interface has no method that removes or replaces a definition.

A hot update is what runs the file twice. When you save it, Vite runs the new copy in the same page, where the registry still holds the old class. In our Vite 8.2.2 run, with a Svelte component importing the file below, the save logged that error and then “Failed to reload /App.svelte,” and the element kept its old markup until we reloaded.

shortcode-kbd.ts
// A component imports this file for its side effect. Save it while `vite dev`
// runs, and Vite runs it again in the same page, where the name is already taken:
// NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':
// the name "shortcode-kbd" has already been used with this registry
class ShortcodeKbd extends HTMLElement {
	constructor() {
		super();
		this.attachShadow({ mode: 'open' }).innerHTML = '<kbd><slot></slot></kbd>';
	}
}

customElements.define('shortcode-kbd', ShortcodeKbd);

Wrapping the call in if (!customElements.get('shortcode-kbd')) silences the error. Svelte 5.57 emits that same check for a component with a customElement tag when it compiles for hot updates, which vite-plugin-svelte turns on in development. The guard hides the collision without changing who owns the name: in our run the page kept the old markup after the save and showed the edit only after a reload. That is close to the exercise’s “ignore the new entry” answer, and during development it’s a fair trade, because a reload starts a new registry.

When you have to own it

Now you ship a reviews widget. Shops add your script and a <store-reviews product="42"> element to their product pages, and you don’t control those pages. A theme and an app can both include the script, or a cached copy of version 2.1 can load before 3.0. Whichever copy loads second calls define for a name that is already taken, and throws.

So you choose the duplicate policy yourself. With the customElements.get guard, the first copy keeps the name: in our Chromium run, 2.1 loaded first, both copies ran without an error, and the page used 2.1. That’s fine when any copy will do. If a newer version has to win, remember that the registry can’t replace a definition, so the version belongs in the tag name, or the newer copy should refuse to start and say why.

Lookup timing is the other decision. Our resolve fails for a name nobody registered; the browser can wait instead. A shop’s own script that runs before yours finds a plain element, and in our run calling widget.refresh() threw “TypeError: widget.refresh is not a function.” customElements.whenDefined('store-reviews') returns a promise that is fulfilled with the class once the name is defined. Elements already on the page are upgraded when define runs, so the same widget.refresh() worked after the promise settled. A name that is never defined leaves the promise pending, so code waiting on an optional widget needs a timeout or a fallback, not just an await.

06 / Already in your toolbox

Names connect independently owned pieces of software.

These public APIs expose registration and later discovery or use, each with its own collision and lifetime policy.

The browser’s CustomElementRegistry

define associates an element name with a constructor, and get retrieves its constructor when registered. Defining an already registered name fails. The browser also rejects reusing a constructor in that registry; our example permits one shortcode under different names. Element construction and lifecycle add responsibilities beyond our directory.

HTML Standard: CustomElementRegistry ↗

Go’s database/sql

Register makes a driver available by name, Drivers lists registered names in sorted order, and Open accepts a driver name. Duplicate registrations and nil drivers panic. This package-level API illustrates registration; our scoped example instead returns an error and lets setup decide what to do.

Go: Register, Drivers, and Open ↗

VS Code’s command registrations

commands.registerCommand associates a command ID with a handler. executeCommand invokes a command by ID. Registration returns a disposable that unregisters the command, making removal part of the API. Our sealed registry leaves removal out.

VS Code: commands API ↗

07 / The parts to watch

A name stays useful only while its meaning is clear.

Sealing the registry closes registration; it does not freeze every collaborator. A returned function can capture mutable configuration or hold a resource. Lookup returns that same collaborator, so its state and lifetime remain part of the shortcode’s contract.

This example has stateless functions and no cleanup to perform. If a shortcode owns a template cache or an open file, decide who shuts it down and what happens to callers that still hold it. Removing a name alone would not revoke a function already returned by lookup.

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 runtime names are a real input. After resolving a known collaborator, pass it to the code that uses it. Explicit injection and a registry can coexist: one describes how dependencies are supplied, the other describes discovery by name.

Plugin loading becomes concurrent or reloadablePublication and removal

The Go example has no locks. Finish registration before publishing the pointer to readers through the application’s normal synchronization. Do not register or reseal concurrently. Sealing is a lifecycle rule, not a synchronization primitive.

In TypeScript, asynchronous module loading can still make registration order depend on timing. The owner governs access to this instance; sharing it across threads would require a suitable ownership and synchronization design.

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.

08 / Make the call

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.

Choosing a registry or a simpler alternative
The situationA useful starting pointWhat changes the choice
A small, closed set of shortcodesA fixed map or switch.Separate contributors need to extend the set without editing consumers.
A caller already knows the shortcodePass that shortcode directly.The caller receives a name at runtime and must resolve it.
Themes and plugins contribute named shortcodesA scoped registry with explicit collision rules.Adding runtime removal or replacement requires ownership and lifetime rules too.
Each request needs a new configured instanceA 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.

09 / Take the idea with you

Explain the directory without saying “registry.”

“Setup records each enabled shortcode under a unique name. The renderer lists those names, finds the function for the tag it read, and calls it. Two themes cannot quietly take the same name.” That explains both the mechanism and the decision.

Try adding a second theme with its own note shortcode to a site you know. Who chooses its public name? Who handles a collision? Then imagine uninstalling it while the renderer still holds the shortcode. Which promise must you add before removal is safe?

Connections to follow nextRelated lessons
  • Strategy makes a policy replaceable through a shared contract. A registry can find that strategy by name; the consumer still delegates behavior to the returned function.
  • Factory owns creation decisions. A registry may hold finished values or factories. Storing an existing shortcode does not create a new shortcode on each lookup.
  • Dependency injection supplies collaborators explicitly. Inject a narrow registry where discovery is needed, or a resolved shortcode where it is already known.
  • Multiton constructs one instance per key on a miss and shares its mutable state. This registry stores shortcodes a contributor already built, and lookup returns that function unchanged.
  • Singleton retains one shared instance and answers every caller with it. A registry holds many named collaborators it did not build; making the registry itself the single global instance is the separate decision Fowler’s form makes.
  • Command can represent work as a value. A command registry associates names with handlers; that alone does not provide queuing, undo, or a history of commands.

Trace one registration, one lookup, and one failure. The ownership decisions should stay visible in every implementation.

Back to design patterns →