A plugin system lets independently developed code extend a product without changing the product’s core release. That promise is attractive because it distributes innovation, but it also creates a long-lived compatibility and security boundary. Host internals change under one owner; plugins may be old, untrusted, unavailable, or written against assumptions the host no longer remembers.
The decisive design question is not how to load code. Dynamic imports, package discovery, subprocesses, and WebAssembly can all do that. The hard work is defining what an extension may observe and change, how the host evolves that contract, how permissions are granted, and how one plugin’s failure is contained. A loader without those policies is merely remote code execution with branding.
A durable architecture exposes narrow domain capabilities and immutable data contracts, not repositories, dependency-injection containers, database handles, or internal object graphs. It discovers plugins through validated manifests, negotiates compatibility before execution, grants least privilege, controls lifecycle and resources, and tests every implementation against the same behavioral suite. Isolation then scales with trust and impact instead of being an afterthought.
Decide What Deserves an Extension Point
An extension point represents a stable variation the product deliberately supports. Good candidates have a clear owner, repeatable input and output, bounded side effects, and several credible implementations: exporting a report format, validating a document, authenticating through an identity provider, enriching a record, or responding to a published event.
Do not turn every internal interface into a public plugin API. Internal interfaces optimize collaboration inside one release train and can evolve through coordinated refactoring. A plugin contract must survive independent release schedules, partial upgrades, adversarial input, and support commitments. Each exported type becomes a liability until a retirement policy says otherwise.
Start from user outcomes. Instead of exposing onRecordSaved(entityManager, record), define an extension such as “produce an export artifact from this versioned report snapshot.” The first leaks transaction timing, persistence shape, and mutable entities. The second states a domain task and allows the host to change storage without breaking exporters.
Classify extension points by interaction:
| Kind | Example | Main contract concern |
|---|---|---|
| Transformer | Convert a canonical document to another format | Determinism, size, resource bounds |
| Validator | Return findings for submitted data | Stable diagnostics and timeout behavior |
| Provider | Supply authentication or storage behavior | Credentials, availability, lifecycle |
| Event consumer | React after a committed business event | Delivery, retries, idempotency |
| UI contribution | Add a panel or command | Sandboxing, accessibility, state ownership |
Separate observation from authority. A plugin that receives an event after a transaction commits is easier to isolate than a synchronous hook that can mutate the transaction. If an extension must veto an operation, define exactly when it runs, how long it may take, what failures mean, and whether all installations must have it. Avoid generic “before” and “after” hooks whose unbounded semantics gradually make core behavior unknowable.
An extension point is justified when the product is willing to document, test, version, support, and eventually deprecate it. Otherwise a built-in module or ordinary application integration is usually cheaper.
Publish Contracts Without Publishing Internals
A plugin contract should use data-transfer values with explicit schemas and domain vocabulary. Inputs should be immutable snapshots or identifiers resolvable through narrow capabilities. Outputs should be validated before the host acts on them. Never pass a live ORM entity, mutable request object, service locator, or raw database connection merely because it is convenient.
Consider a report-export extension. The host can define a small protocol package independent of its implementation:
export type ExportRequestV1 = Readonly<{
reportId: string;
title: string;
columns: ReadonlyArray<Readonly<{
key: string;
label: string;
valueType: 'text' | 'number' | 'date';
}>>;
rows: ReadonlyArray<Readonly<Record<string, string | number | null>>>;
locale: string;
}>;
export type ExportResultV1 = Readonly<{
mediaType: string;
suggestedName: string;
artifact: Uint8Array;
warnings: ReadonlyArray<Readonly<{
code: string;
message: string;
}>>;
}>;
export interface ReportExporterV1 {
export(request: ExportRequestV1, signal: AbortSignal): Promise<ExportResultV1>;
}
Even this compact contract needs documented limits: maximum rows, bytes, execution time, permitted media types, filename rules, locale behavior, cancellation expectations, and whether row order is significant. Type declarations alone cannot express all behavior. Publish a machine-readable schema for process boundaries and runtime validation; compile-time trust ends when independently produced code or serialized messages enter the host.
Prefer capabilities over callbacks into arbitrary host services. If an exporter may fetch a template, give it readGrantedTemplate(templateId), not a general object-store client. A notification plugin might receive a sendThroughApprovedChannel capability rather than network credentials. Capabilities make authority visible in the contract and can enforce quotas, audit, and tenant scope centrally.
Do not promise object identity, call order, thread affinity, or incidental error text unless the contract requires them. Define stable error codes and structured details. Treat plugin output as untrusted: validate size, schema, paths, media types, and references before persistence or display.
Discover and Negotiate Before Loading
Discovery should produce metadata, not execute plugin code. A manifest can state identity, plugin version, publisher, entry point, supported extension contracts, requested permissions, configuration schema, and artifact integrity. Validate it before resolving dependencies or starting a process.
{
"id": "com.example.csv-exporter",
"version": "2.3.1",
"entry": "dist/worker.js",
"contracts": {
"report-exporter": ">=1.1 <2"
},
"permissions": ["artifact:write"],
"configurationSchema": "config.schema.json"
}
Use a controlled registry, installation directory, or explicit administrator selection. Scanning arbitrary runtime paths makes provenance and reproducibility difficult. Verify package signatures or trusted publisher identity where the distribution model supports it, pin artifact hashes, retain the manifest used for each installation, and scan dependencies as part of admission. A signature identifies what was approved; it does not prove the code is safe.
Compatibility negotiation compares contract ranges, required host features, runtime constraints, and permissions before loading. The host should reject an unsupported plugin with a clear diagnostic rather than invoke it and discover a missing method under traffic. Optional features can use explicit capability names, not introspection such as “call this method if it happens to exist.”
Plugin package version and contract version are different. A plugin may release bug fixes without changing the contract. The host may implement several contract versions during a migration. Record the negotiated contract in logs and state so incidents can distinguish package changes from protocol changes.
Configuration belongs to installation state, not executable discovery. Validate it against the plugin’s schema, encrypt secrets outside the manifest, and show which values are host-managed. Configuration upgrades need migration and rollback rules; allowing plugin startup code to rewrite arbitrary configuration creates an uncontrolled lifecycle.
Version for Independent Evolution
Semantic version labels help only when compatibility is defined behaviorally. Within a contract version, adding an optional field with a documented default may be compatible; changing when a hook runs, tightening a timeout, or reinterpreting an error can break plugins without changing a type. Maintain a contract change log and conformance tests alongside the schema.
Use additive evolution carefully. Readers should ignore unknown fields where safe, while writers must not send fields a negotiated older contract cannot interpret. Defaults should produce useful behavior, and absence must differ from an explicit empty value when the domain requires it. Enumerations are especially risky: a plugin compiled with an exhaustive switch may fail on a new member. Design an unknown path or negotiate feature support.
Breaking changes deserve a new major contract or a new extension-point identifier. During migration, adapt at the boundary:
Keep adapters in the host-owned boundary rather than contaminating core domain code with every historical plugin shape. Announce deprecation with a measurable support window, inventory active installations, provide a migration guide and test kit, and emit administrative warnings. Remove an old contract only after usage evidence and policy permit it.
Avoid coupling compatibility to the host’s marketing version. A host release can preserve report-exporter/v1 while changing unrelated features. Conversely, one host release may introduce v2 without disabling v1. Plugins should declare the contract they need, and the host should advertise contracts it implements.
When a security defect requires a breaking restriction, safety overrides graceful evolution. The system needs revocation: block a plugin version or publisher, disable a permission, and prevent execution without waiting for each customer to upgrade manually.
Grant Permissions and Choose Isolation
Plugins range from first-party modules reviewed with the host to third-party code installed from a marketplace. Isolation should follow trust, data sensitivity, and consequence. Common execution models form a ladder:
| Model | Boundary | Appropriate use | Limitation |
|---|---|---|---|
| In-process | Language and host process | Trusted, performance-sensitive extensions | Crash, memory, and authority share the host fate |
| Language sandbox or WebAssembly | Restricted runtime and imports | Portable transformations with narrow capabilities | Sandbox gaps, runtime limits, and integration overhead |
| Subprocess | OS process, IPC, scoped identity | Less-trusted plugins needing richer runtimes | Serialization, supervision, and startup cost |
| Container or remote service | OS/network and deploy boundary | High-risk or independently scaled integrations | Highest latency and operational complexity |
An in-process plugin cannot be made safe by a TypeScript interface. It can consume CPU, allocate memory, call process APIs, mutate globals, or crash the runtime unless the language and host enforce otherwise. Timeouts stop waiting; they do not necessarily stop synchronous code. Use process, WebAssembly, or stronger isolation when termination and resource enforcement matter.
Permissions should be declarative, reviewed at installation, and granted per installation or tenant where relevant. Examples include reading a specific data class, writing artifacts, making outbound requests to approved domains, or receiving selected events. Deny undeclared capabilities. Pass short-lived scoped credentials through host brokers rather than long-lived platform secrets.
Apply CPU, memory, output-size, concurrency, and rate limits at an enforceable boundary. Network egress should be denied by default for untrusted code and mediated through an audited client when required. Filesystem access should use a temporary scoped directory with traversal protection and cleanup. Tenant data must remain scoped even when one plugin installation serves several tenants.
Isolation also covers availability. Give plugins separate queues or concurrency budgets so one slow extension cannot occupy all host workers. Synchronous extension points need short deadlines and explicit fallback semantics. Optional plugins should normally fail without taking down the core operation; mandatory policy plugins may need to fail closed.
Work Through a CSV Export Plugin
Assume an analytics product supports built-in PDF export and wants partners to add report formats. A partner supplies a CSV exporter. The host does not expose report repositories or storage. Instead, it owns a report-exporter/v1 contract and launches partner exporters as subprocesses under a restricted identity.
At installation, the administrator selects the signed package from an approved registry. The host verifies its hash, validates the manifest, confirms that its >=1.1 <2 range intersects the host’s 1.2 implementation, and displays the requested artifact:write permission. The plugin requests no network or arbitrary file access. Configuration specifies delimiter and newline style through a validated schema.
When a user requests CSV, the host authorizes access to the report and materializes a bounded, tenant-scoped snapshot. It sends a serialized ExportRequestV1 plus invocation ID and deadline over framed IPC. The plugin returns metadata and bytes. The host rejects an unsupported media type, unsafe filename, oversized artifact, malformed warning, or response after cancellation. Accepted bytes go through the host’s artifact capability, which applies retention, audit, and download authorization.
Suppose the subprocess exits after writing half a response. The supervisor discards the partial frame, records a structured plugin_crashed outcome, and may retry once only if the invocation is defined as side-effect-free. The core report remains intact. Repeated crashes open a per-installation circuit and disable the exporter while leaving PDF export available. Administrators see the negotiated contract, plugin version, recent failures, and a remediation action.
Later, report-exporter/v2 adds streaming input for reports too large to materialize. The host retains the V1 adapter for bounded reports and advertises V2 separately. The partner can release a dual-contract plugin, but V1 installations continue to work. The host never hands V1 plugins an unbounded stream disguised as the old array because that would violate the old memory and timing expectations.
This example makes ownership clear. The host owns authorization, snapshots, limits, artifact persistence, audit, and user-facing errors. The plugin owns CSV encoding within the contract. Neither side needs the other’s internal model.
Control Lifecycle, State, and Failure
Define lifecycle states such as discovered, validated, installed, configured, starting, ready, draining, stopped, disabled, and removed. Transitions should be idempotent and observable. A plugin is not routable until readiness succeeds. During upgrade, stop assigning new invocations, let bounded work drain, then terminate after a deadline.
Startup hooks should not perform unbounded migrations or external calls while blocking the host. If a plugin owns durable state, give it a versioned namespace and an explicit migration protocol with backup, progress, and rollback. Prefer host-managed state APIs over direct database access. Removing a plugin needs a retention decision: delete its state, archive it, or preserve it for reinstall, all under tenant and compliance policy.
Failures need stable categories: incompatible, invalid configuration, permission denied, timeout, resource exceeded, protocol violation, crash, unavailable dependency, and invalid result. Map them to retry, disablement, fallback, and operator action. Do not retry deterministic protocol violations. Bound retries for transient remote failures and preserve idempotency keys where side effects are possible.
Health should be per plugin installation and extension point, not one global boolean. A plugin can be ready to validate documents while its optional network enrichment is degraded. Keep liveness checks cheap; a check that calls every dependency can create synchronized load. Use actual invocation outcomes for operational health.
Upgrades should be canaried against selected tenants or synthetic traffic when the distribution model permits it. Retain the previous artifact and configuration until the new version passes readiness and behavior checks. A rollback cannot undo plugin-owned state unless migrations are backward compatible or reversible, so declare that boundary before upgrade.
Build a Conformance and Operations Discipline
Publish a plugin development kit containing schemas, generated types where useful, a reference adapter, fixtures, and a conformance runner. The runner should be black-box: it invokes the same boundary the host uses and tests behavior, not private implementation. Plugin authors can run it locally; the registry and host run it again against the submitted artifact.
For an exporter, test empty reports, maximum supported dimensions, Unicode and delimiter escaping, duplicate column labels, cancellation, deadline expiry, malformed configuration, deterministic naming, output-size enforcement, and unknown optional fields. Fuzz serialized input and framing. Run plugins with denied network and filesystem access to prove undeclared dependencies fail during admission rather than production.
Host compatibility tests should execute representative old plugin fixtures against each new host release. Plugin tests should execute against every claimed contract version. Fault injection should kill the process mid-response, delay it, exceed memory, return oversized data, emit invalid schemas, and flood logs. Verify that host capacity, other plugins, and core requests remain available.
Operational telemetry needs plugin ID, package version, negotiated contract, installation, invocation ID, outcome, duration, resource use, and retry count. Control cardinality and protect tenant identity. Never accept unbounded plugin log messages directly into shared logging; limit rate and size. Provide audit records for install, permission change, configuration change, upgrade, disablement, and removal.
Recurring architecture failures include exposing the dependency container as an API, assuming in-process code is isolated, loading code to inspect its manifest, tying contracts to internal classes, allowing unrestricted network access, interpreting timeouts as termination, and supporting extension versions without an inventory or removal policy. Another failure is excessive flexibility: dozens of generic hooks make execution order and ownership impossible to reason about.
Review each extension point periodically. Measure adoption, support load, failure rate, security findings, and whether plugins use accidental behavior outside the contract. Remove unused hooks through the same deprecation discipline applied to used ones. A smaller trustworthy surface is more extensible over time than a broad unstable one.
Plugin architecture succeeds when independent evolution is constrained by a deliberate boundary. Domain contracts preserve meaning while internals change; discovery and negotiation prevent incompatible execution; capabilities and isolation constrain authority and failure; lifecycle rules make upgrades and removal operable; and shared conformance tests turn documentation into evidence. The host remains free to evolve not because plugins can reach everything, but because the extension surface exposes only what the product is prepared to keep stable.