● David R. Longnecker
Chapter 13 · Architecture & Practice

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.

Watch out

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.

Why this works

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.

the generator, greatly simplified
// 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.

Several plugin folders, each implementing the same interface, feed a generated registry that core imports as one array plugins/markdown-export implements Plugin plugins/word-count implements Plugin plugins/tag-browser implements Plugin plugins/your-next-idea drop in a folder generate script scans plugins/* generated registry.ts [p0, p1, p2] core app imports one array
Each plugin only knows the shared interface. The registry is generated, not maintained. Core imports one array and never names an individual plugin.

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.

Key idea

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.

Also worth naming

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.

Try it

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.