← Architecture
Organize an application A host, a registry, a narrow context

Plugin architecture

Keep the core small. Let others extend it.

You have added a Vite plugin: a small object with a name and a few hooks, and Vite called it at the right moment. Your own app can work that way too. Let’s build a note editor that other teams extend, and see what happens when one of their features throws.

TypeScriptGoOne note editor, a host and its extensions, four recorded builds.

01 / The prompt

“Other teams will add features of their own.”

A docs team’s note editor needs three features: trim trailing spaces on save, a “table of contents” command, and a word count in the status bar. The request ends with one more line: other teams will add features over the next few months. Ask an agent for it and you get a server that does the three things, and works.

Then the localization team ships smart quotes, and a writer saves a note about a 27" monitor. The question the prompt never asked is what another team’s code is allowed to touch, and what happens to the writer’s note when that code throws.

Editors answered this long ago. Visual Studio Code runs every extension through an extension host, because “misbehaving extensions should not impact the user experience” (VS Code, Extension Host).

02 / Name the shape

A small host, and what extends it.

In a plugin architecture the core is a host: it owns when things happen, in what order, and what happens when one of them fails. Features are extensions. Each one registers through a context that offers a few named ways in, and it never sees the host itself.

The host owns order, versions, and failure. An extension owns its feature, and touches only what its context offers.

Who owns each part of the editor
WhatOwnerWhy
When save steps run, and in what orderHostEach step declares an order; registration order is an accident.
Which API version is acceptedHostAn extension written for another version is refused before it registers.
What a failing step does to the saveHostOnly the host can skip one step and keep the rest.
Trimming, contents, word countTheir extensionsThe features that ship are extensions too, so the host has no special cases.
Smart quotesThe localization teamTheir code, their bug, their fix.
Removing a featureHostEverything an extension registered is tagged with its name.

Words to put in a prompt or a review

Host
The small core that loads extensions and decides when their code runs.
Extension
A feature with a name and an API version that registers through a context.
Extension point
One named way in: a save step, a menu command, a status item.
Context
What an extension receives: the extension points, and nothing else of the host’s.
API version
The contract an extension was written for, checked when it registers.
Isolation
One extension’s failure stays inside its own step.
Where it meets other shapesHexagonal, pipes, and patterns

Hexagonal / ports & adapters also has a core and things plugged into it, but the core calls out through ports it defines: the plugged-in parts are infrastructure. Here the plugged-in parts are features, and the core calls them. The save steps form a small pipeline, and each extension point is a Strategy or an Observer the host holds a list of.

03 / Follow one note

Watch another team’s bug meet two shapes.

The host loads the editor’s features, runs a save, and takes in the localization team’s smart quotes. Then a note about a 27-inch monitor, typed with a straight inch mark, is saved with flags and with the host. Last, a feature is removed. Open Try it to choose the extensions and write the note.

Plugin architecture

Whose bug loses the note?

A host with extensions

  1. trimregisters a save step, order 10

…

01/ 06
The host loads the extensions

The host loads the extensions

registers a save step, order 10

Reduced motion: choose a scene to see its completed state.

Read this scene

registers a save step, order 10

trim: registers a save step, order 10.

Watch restarts the story when you come back. Step through keeps your step. Try it runs a fresh host for every save.

04 / Read the shape

A context, a host, and one line per feature.

Basic form is what an extension sees and the three that ship. In the wild is the host, beside the same features as flags. At the call site is where features are added: one registration each.

Notice that the host catches a throwing step inside the loop, not around it. The save keeps the text from before that step and goes on to the next.

The context an extension receives, three ways in and nothing else, and the three features that ship with the editor written as extensions.

TypeScriptReading
editor.ts
// What an extension may touch: three ways in, and nothing else of the host's.
export type Context = {
	onSave(order: number, step: (text: string) => string): void;
	command(name: string, run: (text: string) => string): void;
	status(show: (text: string) => string): void;
};
export type Extension = { name: string; api: number; activate(ctx: Context): void };

export const trim: Extension = {
	name: 'trim',
	api: 1,
	activate: (ctx) => ctx.onSave(10, trimLines)
};
export const contents: Extension = {
	name: 'contents',
	api: 1,
	activate: (ctx) => ctx.command('Insert table of contents', insertContents)
};
export const wordCount: Extension = {
	name: 'word-count',
	api: 1,
	activate: (ctx) => ctx.status((text) => `${countWords(text)} words`)
};
GoAlongside
main.go
// What an extension may touch: three ways in, and nothing else of the host's.
type Step func(text string) (string, error)

type Context interface {
	OnSave(order int, step Step)
	Command(name string, run func(text string) string)
	Status(show func(text string) string)
}

type Extension struct {
	Name     string
	API      int
	Activate func(ctx Context)
}

var trim = Extension{"trim", 1, func(ctx Context) { ctx.OnSave(10, trimLines) }}
var contents = Extension{"contents", 1, func(ctx Context) {
	ctx.Command("Insert table of contents", insertContents)
}}
var wordCount = Extension{"word-count", 1, func(ctx Context) {
	ctx.Status(func(text string) string { return fmt.Sprintf("%d words", countWords(text)) })
}}
The behavior these examples promiseChecked by 14 shared scenarios
  • Save steps run by declared order: trim at 10, smart quotes at 20, whatever order they registered in.
  • A step that throws is skipped for that save; the note is stored with the text from before it, and the editor is told which extension was skipped. Smart quotes stays installed for the next note.
  • An extension written for API 2 is refused, with the reason; nothing of it is registered.
  • Removing an extension removes every command and status item it registered.
  • The flags version throws when smart quotes throws, and stores nothing.

Every expectation in the shared cases was produced by a separate model written from these rules and kept beside the examples, not copied from either implementation.

Reading the TypeScriptA context object of three functions

createHost keeps its lists in a closure, so the only way to reach them is the context passed to activate. Every registration is tagged with the extension’s name, which is what makes remove one filter per list.

Reading the GoContext is an interface

Context is an interface with three methods; each extension receives a small struct that carries its own name, so it cannot register under another’s. A save step returns an error instead of throwing, and the host skips the step when it does.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types editor.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module heyrian.dev/lessons/plugin-architecture

go 1.23
installed: trim, contents, word-count, smart-quotes · refused: spellcheck wants api 2, host has 1
save: "Release checklist\n## Before\nSay “ship it” when green.\n## After\nTag the build." · 12 words
contents: "Contents: Before, After"
27" with a host: saved "Desk setup\nMount the 27\" monitor." · smart-quotes skipped: unmatched quote
27" with flags: save failed, unmatched quote; nothing saved
removed contents: installed trim, word-count, smart-quotes · commands 0

05 / Review the agent’s diff

“Now extension errors surface.”

A real bug hid behind a notice for a week, so making errors loud looks like a fix. Read what else it makes loud.

The agent’s pull request

“A bug in trim went unnoticed for a week, because extension errors only became a notice nobody read. Now they fail the save, so we’ll hear about them. All 18 tests pass.”

// host.ts, save()
			  for (const hook of steps) {
			(removed)    try {
			(removed)      text = hook.step(text);
			(removed)    } catch (error) {
			(removed)      notices.push(`${hook.owner} skipped: ${(error as Error).message}`);
			(removed)    }
			(added)    text = hook.step(text); // let extension errors surface
			  }
			
You are reviewing this change. What do you do?

06 / How it fails

Another team’s code will fail. Decide where that stops.

Each row is a shared scenario unless it is marked as authored.

Failure modes of saving one note
What goes wrongWhat the writer seesHostFlags
Wrong: an extension throws on an inch markTheir note, or an errorStored, trimmed; “smart-quotes skipped”Nothing stored
Incompatible: an extension written for API 2The feature is missingRefused with a reasonNo check; it runs against whatever changed
Leftovers: a feature is removedA menu item that does nothingIts command and status item go with itDelete each if by hand
Out of order: a step that must run last is loaded first (authored)A step sees text another has not finishedDeclared order wins, whoever loads firstOrder is wherever someone put the line
Too much reach: an extension given the host (authored)Another team’s feature breaksNot possible through the contextEvery feature can touch every other
Quiet: a skipped step nobody reads about (authored)Notes saved without the featureOnly as good as where the notice goesNot applicable: the save fails

A host contains failure the way a bulkhead does: Containing failure covers the general case, and Error boundaries in UI the same idea for components.

07 / Is it worth it?

A host costs a context, a registry, and a version. Here is what it buys.

One save function with flags is easier to read, and for features one team owns it is often enough. Hold both against the changes.

The same four changes, made to each
ChangeFlagsHost
Another team adds a featureThey edit your save functionThey write an extension; one line registers it
Their feature has a bugEvery save can failTheir step is skipped; the note is stored
The save API changesEdit every feature at onceBump the version; old extensions are refused, not broken
A feature is retiredFind each ifRemove one registration

Before building a host, decide what you will measure and the result you would accept:

  • Failed saves caused by an extension, from the host’s own notices.
  • Host files each feature ticket touches: the ticket in section 08 is one.
  • Features per team: a host pays off when the second team arrives.

This page did not run a real editor, so it gives no production numbers.

08 / Ask for it

Two prompts, one ticket, one inch mark.

We sent two agents the same request at the same time, both running Claude Sonnet. One prompt described the editor. The other added an Architecture block: a small host, every feature an extension with an API version, a context that offers a save step with an order, a command, and a status item, and a host that keeps one failing extension from breaking a save. Then fresh agents got the same ticket for each build: wire in the localization team’s vendor/smart-quotes.mjs. A script ran all four builds.

What the checker found, run 2026-09-23
QuestionPlain promptArchitecture prompt
Trim, word count, and contents workYesYes
After the ticket, a save curls the quotesYesYes
Saving “Mount the 27" monitor.”500; the edit is lost200; the edit is stored
The next note with quotesCurledCurled
Files the ticket changed or addedsrc/app.test.ts (+34 −0), src/saveSteps.ts (+5 −1)extensions.ts (+2 −0), extensions/smart-quotes.ts (new), tests/extensions.test.ts (+46 −3), tests/server.test.ts (+13 −0)
The build’s own tests after the ticket14 of 14 pass28 of 28 pass

Both first-round builds worked, and both were built to be extended: the plain one kept save steps, commands, and status items in three lists, with a comment that “other teams can add more steps to this list as new save-time features land”. Both ticket agents added smart quotes without touching the code that runs a save. The difference is what that code does when a step throws.

In the plain build a save is one reduce over the list, so smart quotes’ throw became the request’s 500, and the writer’s edit was not stored. The ticket agent read the localization team’s file, saw that it throws, and wrote a test that expects the 500: “PUT with an unmatched double quote fails rather than saving a broken note”. Losing the edit became the tested behavior. In the architecture build the host catches each step, so the note was stored without curly quotes, and the next note’s quotes were curled.

src/saveSteps.ts · plain prompt, after the ticket
// Smart quotes (localization team, vendor/smart-quotes.mjs) runs after trailing
// whitespace is trimmed.
export const saveSteps: SaveStep[] = [trimTrailingWhitespace, smartQuotes];

export function runSaveSteps(text: string): string {
  return saveSteps.reduce((current, step) => step(current), text);
}
host.ts · architecture prompt, after the ticket
/** Runs every registered save step, in order, over `text`. A step that
 * throws is skipped -- it never breaks the rest of the save. */
applySave(text: string): string {
  let result = text;
  for (const step of this.#orderedSaveSteps()) {
    try {
      const next = step.fn(result);
      if (typeof next === "string") {
        result = next;
      }
    } catch (err) {
      console.error("A save step failed; skipping it and keeping the text as-is.", err);
    }
  }
  return result;
}

The architecture build is not finished either. Its host logs “A save step failed” to the server console, without the extension’s name, and answers 200 as if nothing happened. The writer is not told their quotes were skipped, and the localization team is not told their code failed: the quiet row in section 06.

So the line worth adding to a plain prompt is the one the plain ticket agent decided the other way: when another team’s code fails, the writer’s note is still saved, and the failure is reported with that team’s name. A list of steps makes features easy to add; only a host that owns failure makes them safe to add.

How the runs were made and checkedTwo rounds, recorded as written
  • Both first-round agents received the prompts word for word, in fresh contexts, in the same message. Each ticket agent received only its build’s folder, with the localization team’s file added, and the same ticket.
  • The files each agent wrote are kept byte for byte, with checksums. For every question the checker restores a build and starts a fresh server, so no question sees another’s notes.
  • All four agents stopped their servers by process id. All four also wrote a server log to the system’s temporary folder, against the prompt, and deleted it.
  • This is one sample of each prompt, not a measurement of a model.

09 / Hold it there

Keep the host small, and extensions out of it, by rule and by test.

A host erodes the day someone adds “just one” special case for a feature, or hands an extension the host “just this once”. Three checks.

  1. The language’s own door

    Pass extensions an interface, not the host. In Go, the host’s lists are unexported fields, so an extension in another package cannot reach them. In TypeScript, keep them in a closure or #private fields; a type alone is only a suggestion.

  2. A rule a check enforces

    Forbid the host from importing any extension, and extensions from importing the host’s module except its types. Enforcement layer runs rules like this against real code. This rule was not run here; the checker in section 08 counted the host files each ticket touched instead.

  3. A check on what actually happens

    Register an extension that throws on every call, save a note, and check it is stored. Then remove an extension and check the menu. The checker does the first with a real third-party bug.

    check-runs.mjs
    async function inchMark(dir) {
    	return withServer(dir, async () => {
    		const first = await call('PUT', '/notes/N-20', { text: 'Desk setup' });
    		const edit = await call('PUT', '/notes/N-20', { text: MONITOR });
    		const stored = await call('GET', '/notes/N-20');
    		const next = await call('PUT', '/notes/N-21', { text: 'Say "ok"' });
    		const commands = await call('GET', '/commands');
    		return { first, edit, stored, editKept: stored.body?.text === 'Desk setup\nMount the 27" monitor.', next, commands };
    	});
    }
    
You already configure plugin hostsVite plugins and editor extensions are this shape. A toolbar other teams add to is where you build one.

Where it already is in your components

A Vite plugin is an object with a name and hooks such as transform; Vite is the host and decides when each hook runs and in what order. Editor libraries such as TipTap and CodeMirror are built the same way: the editor is a host, and bold, links, and history are extensions.

When you have to own it

The day another team wants a button in your toolbar or a widget in your status bar. Load their extensions through a context, give each component its own error boundary, and catch errors in their click handlers yourself: a boundary only catches errors while rendering.

The toolbar draws whatever the extensions registered: commands and status items.

ReactAlready in your code
NoteToolbar.tsx
import type { ComponentType } from 'react';
import { loadExtensions, type EditorExtension } from './editor-extensions';

type StatusProps = { text: string };

// The editor draws whatever the extensions registered. Adding a command is
// adding an extension to the list, not editing this component.
export function NoteToolbar({
	extensions,
	text,
	onChange
}: {
	extensions: EditorExtension<ComponentType<StatusProps>>[];
	text: string;
	onChange: (text: string) => void;
}) {
	const { toolbar, status } = loadExtensions(extensions);
	return (
		<div>
			<div role="toolbar" aria-label="Note tools">
				{toolbar.map((item) => (
					<button key={`${item.owner}:${item.label}`} onClick={() => onChange(item.run(text))}>
						{item.label}
					</button>
				))}
			</div>
			<footer>
				{status.map(({ owner, component: Item }) => (
					<Item key={owner} text={text} />
				))}
			</footer>
		</div>
	);
}

10 / Make the call

Build a host when someone else’s code will run in yours.

Keep features as plain code, even flags, while one team writes them all and they ship together. A host with one extension is a registry nobody else uses.

Build a host when other teams, or other companies, add features on their own schedule; when one of their failures must not take down your core job; or when features come and go. Reopen it when the host grows ifs that name a particular extension: that extension needs a new extension point, or it belongs in the core.

Take it with you

Explain it without saying “plugin” or “host”: “The editor has a few slots, save, menu, and status bar, and each team’s feature fills slots. The editor decides when they run, and if one team’s code breaks, the note is still saved.” Then find a place in your code where another team edits your function to add their feature.

Paste into your next prompt, and fill in the blanks

Build <the core job> as a small host that features plug into. Every feature,
including <the ones that ship>, is an extension with a name and the host API
version it was written for. An extension registers through a context that offers
only <save step with an order, menu command, status item>; it never receives the host.
The host orders the steps, refuses other API versions, and removes everything an
extension registered when it is removed. When an extension throws, <the save still
completes>, and the failure is reported with the extension's name.
Adding a feature must not require editing the host.
Connections to follow nextRelated lessons

Take the editor into your own editor. Add an extension that throws on every save, and make sure the note is still stored and someone hears about it.

Back to architecture →