nSelf spent its first stretch as a monolith. Every capability lived in core, which was correct while the capability list was short and became untenable the moment it wasn’t.
The failure mode is predictable, and most tools hit it. Every feature request grows core. Core accumulates opinions about use cases it shouldn’t have opinions about. Eventually you’re maintaining a Donorbox integration inside a backend CLI, which is absurd. And refusing all of it is how a tool stays small and unused instead.
The answer is a plugin boundary. But “add plugins” is not a design. Where the boundary sits is the entire problem, and getting it wrong produces either a plugin API so thin nothing useful can be built on it, or one so wide that plugins can break the core and each other.
The hard problem is the database
Most plugin systems get to sidestep this. A browser extension doesn’t share your database. An editor plugin doesn’t own tables.
nSelf plugins do. A background job queue is not useful without persistence, and standing up a separate Postgres per plugin defeats the point of the tool. So plugins write to the same database core uses, and that immediately raises the question every shared-schema system has to answer: what stops two plugins from claiming the same table name, and what stops either of them from stepping on core?
The convention is namespace prefixing, declared in the manifest. Here’s the real tables field from the jobs plugin:
{
"name": "jobs",
"tables": ["np_jobs_jobs", "np_jobs_queues"],
"permissions": {
"database": ["create", "read", "update", "delete"],
"network": [],
"filesystem": []
},
"multiApp": {
"supported": true,
"isolationColumn": "source_account_id",
"pkStrategy": "single"
}
}
np_ marks it as plugin-owned, then the plugin name, then the table. np_jobs_jobs cannot collide with np_cron_jobs no matter how badly either plugin is written, because the collision is prevented lexically rather than by convention or good behavior.
The part that matters more than the prefix is that tables is declared, not discovered. The manifest states what a plugin will create before it runs. That turns a whole class of runtime problem into an install-time check: conflicts get caught before anything executes, uninstall knows exactly what to drop, and an operator can audit what a plugin will touch without reading its source.
Anything a plugin does that you’d want to audit later should be declared up front. That principle drove most of the rest of the design.
Permissions as a declared capability set
permissions in that manifest is the same idea generalized. A plugin states which capabilities it needs, meaning database operations, network access, and filesystem access, and the install path can show that to an operator before anything runs.
This is a capability model in the ordinary sense, and it’s doing two jobs. The obvious one is security: a plugin that declares no network access and then tries to make outbound calls is visibly lying, and you want that visible. The subtler one is documentation. "network": [] tells a reader something real about what this plugin is, faster than a README will.
Note that the jobs plugin declares database: [create, read, update, delete] and empty network and filesystem. It’s a queue. It touches Postgres and nothing else. That’s legible at a glance, and it’s checkable.
Multi-tenancy has to be in the contract
If someone asked me what separates a plugin system built for production from one built for a demo, I’d point at multiApp.
nSelf can run multiple applications against one backend. If plugins don’t participate in that, they’re single-tenant components in a multi-tenant system, and the failure shows up as one app reading another’s data. So tenancy isn’t optional and it isn’t something a plugin author gets to figure out independently. The manifest declares which column carries the isolation, here source_account_id, along with the primary key strategy that goes with it.
Making that a contract rather than a convention is the difference between isolation you can verify and isolation you hope for. A plugin that declares supported: true and then ignores the isolation column is caught by a test that lives in the platform, not in the plugin.
Version compatibility, stated per plugin
{ "minNselfVersion": "0.9.9" }
Every plugin declares the minimum core version it works against. That’s unglamorous and it’s what makes the whole thing survivable over time.
Without it, upgrading core is a coordination problem across every plugin anyone has installed, and the practical consequence is that you stop upgrading core. With it, an incompatible plugin fails to install with an actionable message instead of failing mysteriously at runtime six weeks later.
Ports work the same way. The jobs plugin declares "port": 3105. Declared statically, checkable for conflict at install time, rather than discovered when two services race for a bind.
What has to be true before an economy exists
Sixty plugins are in the registry today, MIT-licensed and free. There’s a second tier behind a license gate, and the manifest carries the fields to support it: tier, isCommercial, requiresLicense, requiredEntitlements.
The technical machinery for that is not the hard part. Checksums on tarballs, signed releases, an entitlement check in the SDK. All of it is well-trodden. What’s hard is the boundary question, and it’s a design problem rather than an engineering one.
The rule I settled on: anything required to run a correct, secure backend stays free. Backups, jobs, cron, feature flags, auth, audit logging. If a plugin is the difference between a system that loses data and one that doesn’t, charging for it is extortion dressed up as a business model.
Paid tier is for things that are genuinely additional: commercial integrations, operational tooling for teams large enough to need it, capabilities that cost real money to run.
That line has to be principled and stated, because the alternative is worse than charging too much. If developers can’t predict which side of the line a future capability lands on, they won’t build on the platform at all. Uncertainty about whether the thing you’re depending on will get paywalled next year is a much stronger deterrent than a price tag.
What generalizes
Most of this is not plugin-specific. It’s the same set of questions any extensible system has to answer: who owns what namespace, what capabilities are declared versus assumed, how compatibility is expressed, and what happens at the boundary when someone else’s code runs inside yours.
I’d built extension points before, in multi-tenant platforms where each tenant needed isolated behavior and in the self-hosted stack this grew out of. What was different here is that plugin authors are strangers. A tenant configuration that’s wrong hurts one tenant. A plugin that’s wrong ships to everyone who installs it.
That shifts the design from “make it flexible” toward “make it declarable,” because the only things you can enforce are the things a plugin was required to say out loud before it ran.