Why Every App Should Be a Plugin System
NotepadXL Architecture Series
- Part 1: Why Every App Should Be a Plugin System (this post)
- Part 2: The Disposable Pattern — Zero Memory Leaks by Design
- Part 3: Write Once, Deploy Everywhere — One Codebase, Four Platforms
Most applications bolt plugins on as an afterthought. NotepadXL was plugins from day one. Find & Replace? A plugin. Word Count? A plugin. The editor itself is just a shell that knows how to host plugins. This is the story of why we made that decision, and why I think more software should work this way.
The Problem with Monoliths
Every application starts the same way. You build a feature. You build another. You weave them together with shared state, direct function calls, and assumptions that ripple across the codebase. Six months in, adding a new feature means understanding twelve existing ones. A year in, removing one means breaking four others.
We have seen this pattern everywhere — in web apps, desktop apps, mobile apps. The features that were supposed to be "modular" become entangled. The boundaries that were supposed to be clean become porous. The testing that was supposed to be easy becomes an integration nightmare.
NotepadXL is a modern text editor. When we sat down to architect it, we had a choice: build a text editor and then add plugin support later, or build a plugin system and then implement a text editor as plugins on top of it.
We chose the latter. And it changed everything.
What "Plugin-First" Actually Means
In NotepadXL, the core application is deliberately minimal. It provides:
- A tab management system (open, close, switch between documents)
- A text area for editing
- A menu bar, status bar, and panel layout
- A plugin SDK with well-defined APIs
That is it. Everything else — every feature a user would actually care about — is a plugin. Here is the main application component:
export default function Home() {
const { tabs, addTab } = useEditorStore();
useEffect(() => {
if (tabs.length === 0) {
addTab();
}
}, [tabs.length, addTab]);
return (
<div className="flex flex-col h-screen overflow-hidden">
<MenuBar />
<TabBar />
<div className="flex-1 relative overflow-hidden min-h-0">
<Editor />
<FindReplace />
<PanelRenderer />
</div>
<StatusBar />
<PluginDialogRenderer />
</div>
);
}
Notice the simplicity. The shell initializes one empty tab, then renders the layout. The FindReplace component, the StatusBar, the PanelRenderer — they all draw their content from what plugins have registered. Without plugins, the editor is a blank text area with a menu bar that has nothing in it.
Insight
The shell does not know what features exist. It does not import a find-replace module. It does not call a word-count function. It provides surfaces (menus, status bar, panels) and lets plugins claim them. This is the inversion that matters.
The Plugin Manifest: A Contract, Not a Configuration
Every NotepadXL plugin starts with a plugin.json manifest file. This is not just metadata — it is a contract between the plugin and the host application. Here is the manifest for Find & Replace, one of our core plugins:
{
"id": "notepad-xl.find-replace",
"name": "Find and Replace",
"version": "1.0.0",
"description": "Built-in find and replace functionality for Notepad-XL",
"author": {
"name": "Notepad-XL Team",
"email": "hello@actyra.com"
},
"tier": "core",
"main": "src/index.ts",
"permissions": [
"editor:read",
"editor:write",
"editor:selection",
"ui:menu",
"settings:read",
"settings:write"
],
"activationEvents": [
"onStartup",
"onCommand:findReplace.open",
"onCommand:findReplace.findNext",
"onCommand:findReplace.findPrevious"
],
"contributes": {
"commands": [
{
"id": "findReplace.open",
"title": "Find and Replace",
"shortcut": "Ctrl+F",
"category": "Edit"
},
{
"id": "findReplace.openReplace",
"title": "Replace",
"shortcut": "Ctrl+H",
"category": "Edit"
},
{
"id": "findReplace.findNext",
"title": "Find Next",
"shortcut": "F3",
"category": "Edit"
},
{
"id": "findReplace.findPrevious",
"title": "Find Previous",
"shortcut": "Shift+F3",
"category": "Edit"
},
{
"id": "findReplace.close",
"title": "Close Find Panel",
"shortcut": "Escape",
"category": "Edit"
}
],
"menus": [
{
"location": "edit",
"items": [
{ "command": "findReplace.open", "group": "find" },
{ "command": "findReplace.openReplace", "group": "find" }
]
}
]
},
"engines": {
"notepad-xl": ">=1.0.0"
}
}
There is a lot packed into this file. Let me break down the design decisions that make it powerful.
Permissions: Declare What You Need, Nothing More
The permissions array is a security boundary. Find & Replace requests editor:read, editor:write, editor:selection, ui:menu, settings:read, and settings:write. It does not request file:write, network:fetch, or clipboard:write. Why? Because it does not need them.
Now compare this to the Word Count plugin's manifest:
{
"id": "notepad-xl.word-count",
"name": "Word Count",
"version": "1.0.0",
"description": "Display word count in the status bar",
"tier": "core",
"main": "src/index.ts",
"permissions": [
"editor:read",
"ui:statusbar",
"ui:menu"
],
"activationEvents": ["onStartup"],
"contributes": {
"commands": [
{
"id": "wordCount.show",
"title": "Show Word Count",
"category": "View"
}
],
"statusBar": [
{
"id": "wordCount.statusItem",
"alignment": "left",
"priority": 10
}
]
}
}
Word Count requests only editor:read, ui:statusbar, and ui:menu. It cannot write to the editor. It cannot access the filesystem. It can read document content and display a number in the status bar. That is the full extent of its capabilities.
The full permission system supports 14 distinct permissions:
| Permission | What It Grants |
|---|---|
editor:read | Read document content |
editor:write | Modify document content |
editor:selection | Access text selection |
ui:menu | Add menu items |
ui:statusbar | Add status bar items |
ui:panel | Register side panels |
ui:dialog | Show dialog boxes |
file:read | Read files from disk |
file:write | Write files to disk |
file:register | Register custom file types |
settings:read | Read plugin settings |
settings:write | Write plugin settings |
clipboard:read | Read from clipboard |
clipboard:write | Write to clipboard |
This is not an afterthought. This is the principle of least privilege enforced at the architecture level. A community plugin that claims to be a "theme switcher" but requests network:fetch and file:write is immediately suspicious. The manifest makes intentions transparent.
Activation Events: Lazy Loading, Fast Startup
The activationEvents array controls when a plugin actually loads and executes. Word Count has ["onStartup"] — it activates immediately because you always want to see a word count. Find & Replace has both "onStartup" and command-based events like "onCommand:findReplace.open".
The supported activation events are:
onStartup— Activate when the application launchesonFileOpen— Activate when any file is openedonLanguage— Activate for specific language typesonCommand:commandId— Activate when a specific command is invoked
This matters for performance. An application with 50 plugins does not need to load all 50 at startup. A markdown preview plugin can wait until the user actually opens a markdown file. A Git integration plugin can wait until the user runs a Git command. The host application only pays for what it uses.
Contributions: Declarative UI Registration
The contributes section is where a plugin declares what it adds to the application's UI — without any imperative code. Find & Replace declares commands with keyboard shortcuts, and menu items placed under the "Edit" menu. Word Count declares a status bar item aligned to the left with priority 10.
The host application reads these contributions before the plugin even activates. It can build menus, allocate status bar space, and prepare panel layouts based purely on the manifest. This separation between "what I contribute" and "how I behave" is what makes the system fast and predictable.
The Tier System: Trust Boundaries
Not all plugins are equal. NotepadXL defines three trust tiers:
| Tier | Source | Can Disable | Can Uninstall | Trust Level |
|---|---|---|---|---|
core |
Ships with the app | No | No | Full trust |
verified |
Marketplace (reviewed) | Yes | Yes | Reviewed trust |
community |
User-installed ZIP | Yes | Yes | User discretion |
The implementation is straightforward. In the lifecycle manager, when a user tries to disable a core plugin, the system refuses:
async disablePlugin(pluginId: string): Promise<boolean> {
const loader = getPluginLoader();
const plugin = loader.getPlugin(pluginId);
if (!plugin) {
console.error(`Plugin ${pluginId} is not installed`);
return false;
}
// Core plugins cannot be disabled
if (plugin.info.manifest.tier === 'core') {
console.error(`Core plugin ${pluginId} cannot be disabled`);
return false;
}
// Deactivate if active
if (plugin.info.state === 'active') {
await loader.deactivatePlugin(pluginId);
}
this.enabledPluginIds.delete(pluginId);
this.saveEnabledPlugins();
return true;
}
This is a critical design decision. Find & Replace is a core plugin — you cannot disable it because it is considered fundamental to the text editor experience. But a database viewer or a CRM integration? Those are community-tier. Users can enable or disable them freely.
Insight
The tier system answers a question that most plugin architectures avoid: "What happens when a user disables a plugin that the application depends on?" By making core plugins non-disableable, you get the safety of hard-coded features with the clean architecture of plugins. The best of both worlds.
How Simple Is It to Build a Plugin?
The real test of a plugin architecture is not how sophisticated it is. It is how easy it makes the simplest case. Here is the complete Word Count plugin — a fully functional plugin in about 60 lines of meaningful code:
import type {
PluginModule,
PluginContext,
NotepadXLAPI,
StatusBarItem,
} from '@notepad-xl/plugin-sdk/types';
let statusBarItem: StatusBarItem | null = null;
function countWords(text: string): number {
if (!text || !text.trim()) return 0;
return text.trim().split(/\s+/).length;
}
function updateWordCount(): void {
if (!statusBarItem) return;
const doc = /* get active document */;
if (doc) {
const wordCount = countWords(doc.content);
statusBarItem.text = `${wordCount.toLocaleString()} words`;
statusBarItem.tooltip = `Word count: ${wordCount.toLocaleString()}`;
statusBarItem.show();
} else {
statusBarItem.text = '0 words';
}
}
export function activate(api: NotepadXLAPI, context: PluginContext): void {
context.log.info('Word Count plugin activating...');
// Create a status bar item
statusBarItem = api.ui.statusBar.createStatusBarItem({
id: 'wordCount.statusItem',
alignment: 'left',
priority: 10,
tooltip: 'Word count',
});
statusBarItem.command = 'wordCount.show';
context.subscriptions.push(statusBarItem);
// Register the show command
context.subscriptions.push(
api.commands.registerCommand('wordCount.show', () => showWordCount(api))
);
// Update on document changes
context.subscriptions.push(
api.editor.onDidChangeDocument(() => updateWordCount())
);
context.subscriptions.push(
api.editor.onDidChangeActiveDocument(() => updateWordCount())
);
updateWordCount();
context.log.info('Word Count plugin activated');
}
export function deactivate(): void {
if (statusBarItem) {
statusBarItem.dispose();
statusBarItem = null;
}
}
Let me walk through the pattern:
- Export an
activatefunction that receives the API and a context - Use the API to register UI elements, commands, and event listeners
- Push everything into
context.subscriptionsfor automatic cleanup - Optionally export a
deactivatefunction for additional cleanup
That is the entire pattern. Every plugin follows it. The API surface gives you everything you need:
interface NotepadXLAPI {
editor: EditorAPI; // Document access, changes, events
commands: CommandAPI; // Register and execute commands
ui: UIAPI; // Menus, status bar, panels, dialogs
files: FileAPI; // File operations, importers, exporters
settings: SettingsAPI; // Per-plugin persistent settings
context: PluginContext;
}
The EditorAPI gives you document access. The CommandAPI lets you register and execute commands. The UIAPI covers menus, status bar, panels, and dialogs. The FileAPI handles file I/O and custom file type registration. The SettingsAPI gives each plugin its own persistent key-value storage.
A plugin author does not need to understand the application's internal state management (Zustand), its rendering framework (React), or its platform layer (Tauri/Electron). They work with a clean, typed API that abstracts all of that away.
The Initialization Dance
How does the plugin system boot up? Here is the PluginProvider component that wraps the entire application:
export function PluginProvider({ children }: PluginProviderProps) {
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialize = usePluginStore((state) => state.initialize);
useEffect(() => {
let mounted = true;
const initPlugins = async () => {
try {
// Register built-in file types first
registerBuiltInFileTypes();
// Register core plugins
registerAllCorePlugins();
// Initialize plugin system
await initialize();
if (mounted) {
setIsReady(true);
}
} catch (e) {
console.error('Failed to initialize plugin system:', e);
if (mounted) {
setError((e as Error).message);
}
}
};
initPlugins();
return () => { mounted = false; };
}, [initialize]);
return (
<PluginContext.Provider value={{ isReady, error }}>
{children}
</PluginContext.Provider>
);
}
The initialization sequence is deliberate:
- Register built-in file types — so plugins that handle custom files have something to work with
- Register core plugins — tell the system what plugins exist and provide their factory functions
- Initialize — install, enable, and activate plugins based on their tier and activation events
The core plugin registration is explicit and type-safe:
import findReplaceManifest from 'plugins/core/find-replace/plugin.json';
import * as findReplacePlugin from 'plugins/core/find-replace/src/index';
import wordCountManifest from 'plugins/core/word-count/plugin.json';
import * as wordCountPlugin from 'plugins/core/word-count/src/index';
export function registerAllCorePlugins(): void {
registerCorePlugin(
findReplaceManifest as PluginManifest,
() => findReplacePlugin as PluginModule
);
registerCorePlugin(
wordCountManifest as PluginManifest,
() => wordCountPlugin as PluginModule
);
console.log('Core plugins registered');
}
Each core plugin is registered as a manifest plus a factory function. The factory pattern is important — plugins are not instantiated until they are activated. This means the system can read all the manifests (for menu contributions, command registrations, etc.) without executing any plugin code.
A More Complex Example: Find & Replace
Word Count is simple by design. Let's look at how a more complex plugin uses the same pattern. Here is the activation function for Find & Replace:
export function activate(api: NotepadXLAPI, context: PluginContext): void {
context.log.info('Find and Replace plugin activating...');
// Register commands
context.subscriptions.push(
api.commands.registerCommand('findReplace.open', openFind)
);
context.subscriptions.push(
api.commands.registerCommand('findReplace.openReplace', openReplace)
);
context.subscriptions.push(
api.commands.registerCommand('findReplace.close', closeFind)
);
context.subscriptions.push(
api.commands.registerCommand('findReplace.findNext', findNext)
);
context.subscriptions.push(
api.commands.registerCommand('findReplace.findPrevious', findPrevious)
);
context.subscriptions.push(
api.commands.registerCommand('findReplace.replaceCurrent', replaceCurrent)
);
context.subscriptions.push(
api.commands.registerCommand('findReplace.replaceAll', replaceAll)
);
// Load persisted settings
const savedMatchCase = api.settings.get('matchCase', false);
const savedWrapAround = api.settings.get('wrapAround', true);
updateState({ matchCase: savedMatchCase, wrapAround: savedWrapAround });
// Auto-persist settings on change
context.subscriptions.push(
onStateChange((newState) => {
api.settings.set('matchCase', newState.matchCase);
api.settings.set('wrapAround', newState.wrapAround);
})
);
context.log.info('Find and Replace plugin activated');
}
Seven commands, settings persistence, state change subscriptions — and the exact same pattern as Word Count. Register things through the API, push subscriptions into context.subscriptions, done.
The deactivation is equally clean:
export function deactivate(): void {
// Reset state
state = {
isOpen: false,
showReplace: false,
findText: '',
replaceText: '',
matchCase: false,
wrapAround: true,
matchCount: 0,
currentMatch: 0,
};
stateListeners.length = 0;
}
The plugin resets its internal state and clears its listener array. The host system handles disposing the subscriptions. (We'll explore this disposal mechanism in detail in Part 2.)
What You Get When Everything Is a Plugin
Clean Boundaries
Each plugin has its own state, its own commands, its own UI contributions. No shared globals, no tangled imports between features.
Testability
Test a plugin by mocking the NotepadXLAPI interface. No need to boot the entire application to verify that Find & Replace counts matches correctly.
Replaceability
Don't like the built-in word count? A community plugin can provide a better one. Same API surface, different implementation.
User Choice
Community plugins can be enabled and disabled at runtime. Users assemble the editor they want, not the one we decided they should have.
But the deepest benefit is one that is hard to appreciate until you have lived with it: features cannot accidentally depend on each other. Find & Replace cannot reach into Word Count's internal state. Word Count cannot assume Find & Replace exists. Each plugin interacts only with the host API, and the host API is the same for everyone.
The Plugin Lifecycle: Six States
Every plugin in NotepadXL exists in one of six states:
type PluginState =
| 'installed' // Plugin files present, not enabled
| 'enabled' // Enabled but waiting for activation event
| 'activating' // activate() is being called
| 'active' // Running, subscriptions live
| 'deactivating' // deactivate() is being called
| 'error'; // Something went wrong
The lifecycle is deterministic. A plugin moves through these states in a predictable sequence: installed → enabled → activating → active. When disabled or shut down: active → deactivating → enabled (or installed). If anything goes wrong at any point: → error.
The lifecycle manager tracks every transition, fires events for each state change, and handles errors gracefully. If a plugin throws during activation, it enters the error state and the rest of the system continues running. One misbehaving plugin cannot bring down the editor.
The Bigger Question
Here is what I keep coming back to: if this pattern works for a text editor, why does it not work for more software?
Think about the applications you use daily. How many of them would be better if you could swap features in and out? If you could replace the built-in file manager with one that works the way you think? If a settings panel that does not suit your workflow could be disabled entirely?
The reason most applications do not work this way is not technical. The plugin patterns are well-understood — VS Code proved it at scale years ago. The reason is cultural. Building a plugin system first, before building features, feels like over-engineering. It feels like writing infrastructure when you should be writing features.
But the infrastructure is the feature. A well-designed plugin API is a promise to your users: you are in control. You decide what this application does. We provide the platform. You compose the experience.
The Argument in One Paragraph
If your application has more than three features, you are already building a plugin system — you are just building a bad one, without the API contracts, permission boundaries, lifecycle management, or user control that a real plugin system provides. Making it explicit from day one costs more upfront and saves an order of magnitude in maintenance, testing, and user satisfaction.
What's Next
This post covered the "why" and the "what" of plugin-first architecture. In Part 2: The Disposable Pattern, we will go deep into the mechanism that makes this whole system work safely — the Disposable interface and context.subscriptions pattern that makes memory leaks structurally impossible. It is the part that makes experienced developers say "oh, that's clever."
In Part 3: Write Once, Deploy Everywhere, we will look at how one React/TypeScript codebase compiles to Tauri (desktop), Electron (desktop alt), plain web, and Capacitor (mobile) — and the platform abstraction layer that makes it work.
NotepadXL Architecture Series
- Part 1: Why Every App Should Be a Plugin System (this post)
- Part 2: The Disposable Pattern — Zero Memory Leaks by Design
- Part 3: Write Once, Deploy Everywhere — One Codebase, Four Platforms
This is part of my daily developer log. Follow my journey as I learn new skills and build tools with Brian at Actyra.