What a monolith plugin architecture is and when it beats a modular monolith
A monolith plugin architecture is a core application that publishes extension points: defined places where separately owned code can add behaviour, such as a new payment provider, an extra validation rule or a custom report. Modules in a modular monolith look similar but differ in one way. Module boundaries organise code that one organisation changes together. Extension points are a contract with code the core team does not write, review or release.
Teams adopt a plugin system when several internal teams, or external customers, each need their own variation of the same core. Without one, those variations end up as if customer == X branches in the core or as long-lived forks. It suits platform teams that own a core used by many consumers and can afford to treat the extension surface as a product.
Prefer simpler tools when they cover the need. Module boundaries work when the people changing the code also own the core. Configuration works when the variations are values rather than behaviour. Feature flags work when the variations are temporary or known in advance. Avoid a plugin system if a single team owns everything, if every variation is known up front, or if no one will own a public API for years.
| Approach | Who owns changes | Coupling | Upgrade cost |
|---|---|---|---|
| Plugin system | Plugin authors, against a core-owned contract | Low, through the contract | Low if the contract holds; high when it breaks |
| Modular monolith modules | The same organisation | Medium, in-process calls | Low, one coordinated release |
| Configuration | Operators | Low | Low, bounded by the options that exist |
| Feature flags | Core team | Medium, branches stay in core | Low, but stale flags pile up |
| Forking | Each fork owner | High, divergent copies | High, recurring merge work |
Types of extension points and how they work
Plugins hook into the core through four kinds of extension points. Each one fails differently and has its own ordering rules, so choose deliberately.
| Type | Use when | Failure semantics | Ordering concern |
|---|---|---|---|
| Filter | A plugin must transform a value (price, query, payload) | Synchronous; an exception aborts the request or falls back to the input | Each handler sees the previous one's output |
| Event hook | A plugin reacts after something happened | Isolated; log and continue, since the core's action already committed | Usually irrelevant, so no handler should depend on another |
| Strategy | A plugin replaces one behavior (tax calculation, storage) | One implementation; its failure is the feature's failure | Selection, not sequence; reject duplicate registrations at startup |
| Contributed resource | Routes, UI fragments, migrations, CLI commands | Validated at boot; collisions should stop startup | Migrations need explicit dependencies; routes need unique paths |
For filters, the core sorts registered handlers by declared priority, with ties broken by registration order. It then passes the value through each handler and uses the final result. For events, the core iterates the same sorted list but wraps every call so that one failing listener cannot block the others.
Every hook is a public API. Each one you add fixes a piece of internal behavior in place. If you add too many, you can't refactor the core. If you add too few, teams fork again.
Filters also create conflicts that no single team owns. Suppose one plugin applies a discount and another rounds the price. The total then depends on priority numbers that neither team chose with the other in mind. Make the full chain inspectable and test common plugin combinations.
import logging
log = logging.getLogger("hooks")
class Hooks:
def __init__(self):
self._handlers = {} # hook name -> list of (priority, seq, fn)
self._seq = 0
def register(self, name, fn, priority=10):
# seq records registration order so ties on priority stay deterministic
self._handlers.setdefault(name, []).append((priority, self._seq, fn))
self._seq += 1
def _ordered(self, name):
entries = sorted(self._handlers.get(name, []), key=lambda h: (h[0], h[1]))
return [fn for _, _, fn in entries]
def apply_filter(self, name, value):
# Each handler sees the previous output; an exception aborts the request
for fn in self._ordered(name):
value = fn(value)
return value
def emit(self, name, payload):
# The core action already committed, so one failing listener must not block the rest
for fn in self._ordered(name):
try:
fn(payload)
except Exception:
log.exception("listener %s failed on %s", fn.__name__, name)
hooks = Hooks()
hooks.register("cart.total", lambda t: t * 0.9, priority=10) # discount plugin
hooks.register("cart.total", lambda t: round(t, 2), priority=20) # rounding plugin; swap priorities and the total changes
hooks.register("order.placed", lambda o: print("send receipt", o["id"]))
total = hooks.apply_filter("cart.total", 19.99)
hooks.emit("order.placed", {"id": "ord_1042", "total": total})
A minimal hook registry that runs filters and events in priority order with ties broken by registration order: filters chain each handler's output into the next and let exceptions abort, while events isolate each listener's failure.
Designing the plugin contract: narrow interfaces, plain data and capability objects
The contract is the only part of the core that plugins should see. Keep each interface narrow: one method per extension point, taking a small input type and returning a small output type. A pricing hook receives line items and a currency code, not the Order aggregate with its repository handle and lazy-loaded customer.
Pass dependencies in explicitly. Give each plugin a capability object at call time instead of letting it reach into a service locator or global container. That object holds only what the plugin was granted, such as a logger, a read-only config view, or a scoped key-value store. Because access is explicit, a reviewer can read a plugin's grants in one place. The core can also revoke or stub any capability in tests. In the example below, the hook receives a context and returns a plain result. It holds no references to core internals.
Write down what the core promises and what it does not. For example, the core might promise that a hook runs once per request, that input lists are never null, and that a plugin's returned value is used as is. It might also state that call ordering between plugins, thread affinity, and the timing of retries are not guaranteed. Anything you leave unstated will eventually be depended on.
Plain data types are the safer choice, but they cost something. You write mapping code, and validation or derived fields can end up duplicated on both sides. Rich domain objects avoid that work, but they expose internals. Every refactor of the domain model then becomes a breaking change for every plugin. In most cases the mapping layer is the cheaper of the two costs.
// Plain data in and out: no Order aggregate, no repository handles.
export interface LineItem {
readonly sku: string;
readonly quantity: number;
readonly unitPriceMinor: number; // integer minor units, e.g. cents
}
export interface PriceAdjustment {
readonly sku: string;
readonly deltaMinor: number; // negative for discounts
readonly reason: string;
}
// Capability object: only what this plugin was granted, passed at call time.
export interface PricingContext {
readonly currency: string; // ISO 4217 code, e.g. "EUR"
readonly log: (message: string) => void;
readonly config: Readonly<Record<string, string>>; // read-only view
}
// Contract: called once per request; `items` is never null (may be empty).
// Not promised: ordering relative to other plugins, or which thread runs it.
export interface PricingPlugin {
readonly name: string;
adjust(items: readonly LineItem[], ctx: PricingContext): PriceAdjustment[];
}
// Example plugin: depends only on the contract, never on core internals.
export const bulkDiscount: PricingPlugin = {
name: "bulk-discount",
adjust(items, ctx) {
const threshold = Number(ctx.config["minQuantity"] ?? "10");
return items
.filter((item) => item.quantity >= threshold)
.map((item) => {
ctx.log(`bulk discount applied to ${item.sku} (${ctx.currency})`);
const total = item.unitPriceMinor * item.quantity;
return { sku: item.sku, deltaMinor: -Math.round(total * 0.05), reason: "bulk" };
});
},
};
A narrow pricing hook that receives plain line items and an explicit capability object, and returns plain adjustment records, with the core's promises and non-promises written as comments on the contract.
Discovery, registration and the plugin lifecycle
Core can find plugins in three ways. Static registration lists every plugin in a single file inside the core. It is easy to read, but adding a plugin means a core commit. A manifest per plugin keeps the declaration next to the plugin's own code. Language-native discovery, such as entry points or a service loader, finds anything installed on the path. That is convenient, but load order then depends on install order.
Prefer explicit registration with declared ordering. When two plugins hook the same event, the question "why did B run before A?" should be answered by a file you can read. Implicit scanning answers it with filesystem or dependency-resolver behaviour.
A manifest should declare:
- name and version:
invoice-tax,2.3.0 - supported API range:
>=4.0, <5.0 - declared hooks:
order.before_commit,invoice.render - ordering hints:
after: currency-rounding - required capabilities:
read:orders,write:invoice_lines
| Stage | Core checks | On failure |
|---|---|---|
| Load | Manifest parses, plugin module imports | Skip plugin, log, continue |
| Validate | API range, hooks exist, capabilities grantable | Refuse to activate, report the reason |
| Initialize | Plugin's setup succeeds within a timeout | Unregister its hooks, mark the plugin degraded |
| Run | Hook returns without error | Decide per hook: fail closed or fail open |
| Shut down | Plugin releases resources within a timeout | Log, then force-stop |
Make the closed/open decision per hook and record it in the hook definition. A tax calculation that throws should block the order, because an incorrect invoice costs more than a failed request. An analytics hook that throws should be logged and skipped.
# plugins/invoice-tax/plugin.yaml
# Read by the core at the Load stage; checked at Validate before any plugin code runs.
name: invoice-tax
version: 2.3.0
# Core refuses to activate the plugin if its own plugin API version is outside this range.
api: ">=4.0, <5.0"
# Every hook must already exist in the core's hook registry, or validation fails.
hooks:
- order.before_commit
- invoice.render
# Ordering hints are resolved into one explicit, inspectable order at startup.
# If currency-rounding is missing or the hints form a cycle, validation fails
# with an error naming both plugins.
ordering:
after:
- currency-rounding
# Core grants only these. A capability the deployment does not allow blocks activation.
capabilities:
- read:orders
- write:invoice_lines
# Bounds the core enforces at Initialize and Shut down.
lifecycle:
init_timeout_seconds: 5
shutdown_timeout_seconds: 10
# Note: the fail-closed or fail-open policy is not set here. The core sets it in
# its own hook definitions, for example order.before_commit: fail_closed and
# analytics.order_placed: fail_open. A plugin cannot loosen the policy for a hook
# it subscribes to.
A per-plugin manifest that declares its identity, supported API range, hooks, ordering hints, required capabilities and lifecycle timeouts, all of which the core validates before activating the plugin.
Versioning the plugin API and owning plugin data
The plugin API needs its own version number, separate from the core app's release number. The core can ship weekly while the contract stays at 2.x for a year. Each plugin declares the range it supports, such as plugin_api: ">=2.1 <3.0". At load time the core compares that range with its current API version and refuses to activate the plugin if the version falls outside it. A clear startup error is better than a missing method at runtime.
Breaking changes go through a deprecation window. The old call keeps working but logs a warning that names the plugin and the planned removal version. If the new contract differs in shape, an adapter shim converts old calls into new ones. Shims cost real effort. They need their own tests and someone has to delete them later. Most plugin authors will also wait until the deadline, so plan for a spike of breakage reports in the release that removes the shim.
Owning plugin data
Each plugin gets its own namespace, such as tables prefixed invoicing_, a separate schema or an isolated key prefix. A plugin registers its migrations with the core, for example register_migration("invoicing", "0003_add_tax_region"), and the core runs them in order and records which have been applied. Plugins never write to core tables directly. They go through core services, so core invariants stay enforced in one place.
This isolation has costs:
- Backups must cover every plugin namespace.
- Restores must keep the plugin namespaces consistent with the core.
- Cross-entity queries and reports have to join across ownership boundaries, or read from an exported projection.
- Transactions that span core and plugin data need either a shared transaction exposed by the core or eventual consistency through events.
import sqlite3
from packaging.specifiers import SpecifierSet
from packaging.version import Version
# Contract version, released independently of the core app version.
PLUGIN_API_VERSION = Version("2.4")
def check_compat(plugin, declared_range):
# Refuse activation at startup instead of failing on a missing method later.
if PLUGIN_API_VERSION not in SpecifierSet(declared_range):
raise RuntimeError(
f"{plugin} supports plugin_api {declared_range}; core provides {PLUGIN_API_VERSION}")
MIGRATIONS = {} # namespace -> [(migration_id, sql)]
def register_migration(namespace, migration_id, sql):
# Plugins only supply steps; the core owns ordering and bookkeeping.
MIGRATIONS.setdefault(namespace, []).append((migration_id, sql))
def run_migrations(conn, namespace):
conn.execute("CREATE TABLE IF NOT EXISTS core_plugin_migrations ("
"namespace TEXT, migration_id TEXT, PRIMARY KEY (namespace, migration_id))")
applied = {row[0] for row in conn.execute(
"SELECT migration_id FROM core_plugin_migrations WHERE namespace = ?", (namespace,))}
# Zero-padded IDs make lexical sort match intended order.
for migration_id, sql in sorted(MIGRATIONS.get(namespace, [])):
if migration_id in applied:
continue
conn.execute(sql)
conn.execute("INSERT INTO core_plugin_migrations VALUES (?, ?)", (namespace, migration_id))
conn.commit()
# Plugin side: declares its range and touches only invoicing_* tables.
check_compat("invoicing", ">=2.1,<3.0")
register_migration("invoicing", "0001_create_invoices",
"CREATE TABLE invoicing_invoices (id INTEGER PRIMARY KEY, customer_id INTEGER, total_cents INTEGER)")
register_migration("invoicing", "0003_add_tax_region",
"ALTER TABLE invoicing_invoices ADD COLUMN tax_region TEXT")
run_migrations(sqlite3.connect("app.db"), "invoicing")
A core-side check that refuses to load a plugin whose declared plugin_api range excludes the current contract version, plus a core-managed migration registry that applies a plugin's namespaced migrations in order and records which ones have run.
Isolation options, security and observability
Isolation is a spectrum, and each step trades runtime safety for operational cost.
| Option | Protects against | Costs |
|---|---|---|
| In-process, conventions only | Nothing enforced | None beyond discipline |
| In-process, restricted APIs + timeouts | Accidental misuse, some blocked request paths | Wrapper overhead, partial guarantees |
| Separate process over RPC | Crashes, memory leaks, blocked request paths | Serialization, network failure modes, extra deployables, harder local setup |
| Sandboxed runtime | Privilege misuse, plus the above | Restricted language features, host-call overhead, tooling maturity |
Per-request timeouts only partly help in-process. The caller can stop waiting, but in most runtimes it cannot safely kill a hook that is spinning on CPU, holding a lock or allocating memory. The request returns an error while the work keeps running and still consumes the shared heap and thread pool. Real preemption requires a process or sandbox boundary.
Restricting which APIs a plugin receives is not a trust boundary by itself. In the same address space, reflection, direct imports of core internals or native calls can bypass the capability objects you pass in. Restricted APIs prevent mistakes. Against hostile or untrusted code, you need one of three things: a sandbox that enforces the restrictions, mandatory code review, or signing that ties each artifact to a vetted owner.
Whatever level you choose, tag every log line, metric and trace span emitted inside a hook with the plugin identifier and version. Without that tag, a slow third-party hook looks like a latency regression in the core endpoint that called it, and the wrong team gets paged.
Testing strategy and impact on development cost and engineering time
Contract tests and core CI
The core should publish a contract test kit: a test suite that plugin authors run against their own plugin. It loads the plugin through the real registration path, calls every declared hook with fixture data, and asserts on the result. For example, it checks that a hook returns plain data, never mutates its input, and finishes within its time budget. Because the kit ships with the plugin API, an author learns about an incompatibility on their own machine. The alternative is learning about it from a production incident. The example below shows a minimal contract test for a single hook.
Core CI covers the other direction. On every core change, it runs a representative set of real plugins against the build. Pick the most widely used plugins plus any that use unusual extension points. Fixtures alone cannot show how plugins actually depend on the core, and these plugins can.
Cost
Illustrative estimate (assumed numbers, not a benchmark)
- Fork cost per year = forks × upstream merges per year × hours per merge
- Assume 4 × 12 × 16 = 768 h.
- Plugin API cost per year = (initial design hours ÷ amortization years) + upkeep for docs, shims and the test kit
- Assume (300 ÷ 3) + 400 = 500 h.
With these inputs, the plugin API saves about 268 h a year. The calculation leaves out slower core refactoring, so add that cost when you plug in your own numbers. It also leaves out the time saved when teams ship features without waiting on core changes.
Signs you are forking again
- Plugins import core internals.
- Hooks are added for exactly one plugin.
- Teams pin old core versions for months.
- Representative plugins are dropped from CI because they keep failing.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith plugin architecture, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.