Plugin Architecture
Imagine a notes app that wants to support a dozen optional features: a Markdown exporter, a word-count widget, a tag browser, a daily-journal template generator. Every one of those could be bolted straight into the core codebase — or they could each live in their own folder, invisible to core, and simply announce themselves. This chapter is about the second approach, and why it scales so much better once more than one person is touching the code.
The problem with one giant file #
The naive way to add optional features to an app is a single list somewhere in core:
the naive approach — a hand-maintained list// core/features.ts
import { markdownExport } from "../features/markdown-export";
import { wordCount } from "../features/word-count";
import { tagBrowser } from "../features/tag-browser";
// ...one more import every time someone adds a feature
export const features = [markdownExport, wordCount, tagBrowser];
This works fine for three features written by one person. It stops working once five contributors
are adding features in parallel. Every new feature means editing the same two lines of
features.ts that everyone else is also editing, which means constant merge conflicts on a
file that has nothing to do with what any individual feature actually does. Worse, core now has a
compile-time dependency
on every single feature, whether or not that feature is even installed. Deleting a feature means
remembering to also delete its line from this file — easy to forget, and nothing will warn you.
This pattern is sometimes called a "god file" or "the registration bottleneck." It's not a
TypeScript problem or a JavaScript problem — it shows up in any ecosystem where extensions are
wired up by hand in one place (browser extension manifests, old-school CMS plugin lists, C# services
registered in a single Startup.cs). The fix is always the same shape: stop hand-listing,
start discovering.
Designing a shared contract #
Before you can discover plugins automatically, every plugin has to agree on the same shape. That shape is a interface — a contract that says "if you want to be a plugin, you must export something with exactly these fields." For the notes app, that might look like this:
the plugin contract every feature implements// core/plugin-types.ts
export interface Plugin {
id: string;
metadata: {
title: string;
description: string;
};
onInstall?(): void;
onNoteSaved?(note: Note): void;
}
one concrete plugin implementing it
// plugins/word-count/plugin.ts
import type { Plugin } from "../../core/plugin-types";
export const wordCountPlugin: Plugin = {
id: "word-count",
metadata: {
title: "Word Count",
description: "Shows a live word count in the editor footer.",
},
onNoteSaved(note) {
console.log(`${note.title}: ${note.body.split(/\s+/).length} words`);
},
};
Notice what core knows about this plugin: nothing beyond the Plugin shape. Core doesn't
import word-count by name, doesn't know it exists, and doesn't care how it counts words. It
only knows how to call onNoteSaved on any object that has one. That's the whole
trick — core is written once, against the contract, and never again against a specific plugin.
This is the same idea as a callback: instead of core hard-coding what happens when a note is saved, it hands that moment off to whatever plugins registered interest. Core provides the slot; plugins provide the behavior.
Convention over configuration: discovering plugins #
With a shared contract in place, the remaining problem is purely mechanical: how does core find every
folder under plugins/ without anyone editing a list by hand? The answer is a small script
that runs before the dev server starts and before every build — a
code generation
step. It scans the filesystem for files matching a convention (say, every plugins/*/plugin.ts)
and writes out a single, disposable file that imports all of them.
// scripts/generate-plugin-registry.mjs
import { readdirSync, writeFileSync } from "node:fs";
const folders = readdirSync("plugins", { withFileTypes: true })
.filter((entry) => entry.isDirectory());
const imports = folders
.map((f, i) => `import plugin${i} from "../plugins/${f.name}/plugin";`)
.join("\n");
const list = folders.map((_, i) => `plugin${i}`).join(", ");
writeFileSync(
"core/generated/plugin-registry.ts",
`${imports}\n\nexport const plugins = [${list}];\n`
);
what it produces — nobody hand-writes this file
// core/generated/plugin-registry.ts (generated, do not edit)
import plugin0 from "../../plugins/markdown-export/plugin";
import plugin1 from "../../plugins/word-count/plugin";
import plugin2 from "../../plugins/tag-browser/plugin";
export const plugins = [plugin0, plugin1, plugin2];
Core imports exactly one thing: { plugins } from "./generated/plugin-registry". Adding
a new feature is now just adding a new folder under plugins/ with a plugin.ts
that implements the contract. Nobody touches core. Nobody touches a shared list. The next build
regenerates the registry and the new plugin simply shows up.
The tradeoffs, honestly #
Plugin architecture is not free. You're trading direct, visible wiring for indirection, and that trade only pays off once the number of contributors or the number of optional features gets large enough that the god file becomes the actual bottleneck.
What you gain
New features can be added, removed, or disabled without touching core or any other feature. Contributors stop stepping on each other's diffs. Core stays small and stable because it depends on an interface, not on a growing list of concrete features.
What it costs
"Find usages" in your editor no longer tells the whole story — a plugin is invoked by matching a contract, not by a direct call core makes. You have to trust the generator ran and trust the convention was followed. Debugging "why didn't my plugin load" often means checking the generated file, not the plugin itself.
Plugin architecture is convention over configuration: instead of every feature being individually configured (added to a list, registered by hand), features simply follow a convention (a folder shape, a file name, an exported interface) and a build step interprets that convention automatically. You saw the same generator pattern already, at a smaller scale, whenever a framework auto-discovers files by folder structure.
The gap nothing above solves: trust #
Every example in this chapter assumes a plugin's code is trustworthy — because in a
convention-over-configuration system, a plugin that satisfies the Plugin interface runs
with exactly the same privileges as core code. There is no sandbox, no permission system, and no
review gate built into the mechanism itself. The discovery script imports whatever it finds; whatever
it finds then executes with full access to the file system, the network, and every other decorated
service on the app instance, including the database.
That's a reasonable trade when every plugin author is you, or a small team you already trust with commit access — which describes most of the examples in this course. It stops being reasonable the moment "plugin" means "something a third party wrote and a user installed," the way a browser extension or a WordPress plugin marketplace works. Nothing about the pattern taught here is safe for that use case without adding real isolation: running plugin code in a separate process or a sandboxed runtime, granting capabilities explicitly instead of by default, and reviewing code before it's ever allowed to run. Convention-over-configuration solves discovery; it says nothing about trust, and conflating the two is the most common mistake teams make when this pattern grows past its original scope.
Two smaller gaps in the examples above: a plugin's onSave or similar hook that throws
synchronously has no isolation from the rest of the save flow — one broken plugin can take down
an operation that has nothing to do with it, unless the caller wraps each plugin invocation in its
own try/catch. And nothing stops two plugins from declaring the same id, even though the
whole discovery mechanism trusts each plugin's self-reported id to be unique.
Sketch the Plugin interface for a blog engine instead of a notes app — what
lifecycle hooks would a "related posts" plugin need versus a "comment spam filter" plugin? Write both
as objects implementing the same interface, the way wordCountPlugin does above.