The Plugin System
Every business feature is a plugin. A plugin is a Python package with a class, a blueprint, models, services and migrations.

The plugin class
A plugin subclasses BasePlugin and is defined in the package's __init__.py (it must be defined there, not re-exported):
from src.plugins.base import BasePlugin, PluginMetadata
class MyPlugin(BasePlugin):
@property
def metadata(self):
return PluginMetadata(name="my-plugin", version="1.0.0",
dependencies=[])
def initialize(self, config=None):
merged = {**DEFAULT_CONFIG, **(config or {})}
super().initialize(merged)
def get_blueprint(self):
from plugins.my.my.routes import my_bp
return my_bp
def get_url_prefix(self):
return "/api/v1/my"
def on_enable(self): ...
def on_disable(self): ...Lifecycle
Every plugin moves through a deterministic lifecycle, driven by the PluginManager:
discovered → initialized → enabled ⇄ disabled- discovered — the manager scans the
plugins/directory and reads each package'sPluginMetadata(name, version, dependencies). Nothing runs yet. - initialized —
initialize(config)merges the plugin'sDEFAULT_CONFIGwith the stored config. Still no side effects — just settled configuration. - enabled —
on_enable()wires the plugin into the running app: it registers DI providers, event handlers (register_event_handlers), data exchangers, line-item handlers and permissions, and its blueprint is mounted. At boot only plugins markedenabled: trueinplugins.jsonreach this state. - disabled —
on_disable()tears those registrations down. Enable/disable is also a runtime admin action (below); the chosen state is persisted to the shared plugin-state JSON — the single source of truth across the backend, fe-admin and fe-user.
From the admin this lifecycle is the Enable / Disable action on the Backend Plugins list (shown above), where each plugin also reports its version and Active/Inactive status. Toggling it calls POST /api/v1/admin/plugins/<name>/{enable,disable}, which runs the same on_enable/on_disable hooks.
File layout (new convention)
plugins/<name>/
__init__.py # the Plugin class lives here
<name>/ # source dir = the plugin id
models/ # SQLAlchemy models (extend BaseModel)
repositories/ # data access
services/ # business logic
routes.py # Flask blueprint
migrations/versions/ # plugin-owned Alembic migrations
tests/ # unit + integration with own conftest.py
populate_db.py # idempotent demo data
config.json # default config (+ debug_mode toggle)
admin-config.json # admin settings schemaRegistration
- Add the plugin to
plugins/plugins.json(enabled: true) and its config toplugins/config.json. - Put migrations in
plugins/<name>/migrations/versions/— they are auto-discovered byalembic/env.py(noalembic.iniedit needed). - Every plugin ships its own public
VBWD-platform/vbwd-plugin-<name>repository.
Dependencies
A plugin declares the other plugins it needs in PluginMetadata.dependencies — a list of plugin names:
return PluginMetadata(
name="referral", version="1.0.0",
dependencies=["discount", "meinchat"])The PluginManager's dependency resolver uses this to:
- Order enablement — a plugin is enabled only after every dependency is enabled, so its
on_enablenever runs against a missing peer. - Block unsatisfiable enables — if a required plugin is absent, the admin Enable action is blocked with the reason shown, instead of failing at runtime.
- Resolve transitively — the install recipe's
--plugins-list/--all-pluginsflags pull in every transitive dependency automatically: namereferraland it bringsdiscountandmeinchatalong.
The admin surfaces the resolved graph per plugin — each dependency with its required version specifier, the installed version, and a satisfied / blocked status:

The same view scales to deeper graphs — here a bot plugin that pulls in seven peers, each resolved and satisfied:

Version constraints
A dependency can pin a version, not just a name. A bare name (email) means “any version”; a specifier (subscription>=26.7) is checked against the installed version using PEP 440. The resolver enforces this everywhere a plugin could be turned on — at boot (it skips with a WARNING rather than half-wiring handlers), on the CLI, and in the admin.
When a required version is too old, the admin Activate action is disabled with the exact unmet specifier, and the dependency row is flagged:

meinchat needs subscription>=26.7 but only 26.6 is installed, so Activate is blocked with the reason inline and the dependency row marked ✗.If activation is forced anyway, the enable API answers HTTP 422 with the precise reason:

POST /admin/plugins/<name>/enable returns 422 with “Cannot enable 'cms-ai': requires 'cms>=26.7' but 'cms' v26.6 is installed”.Disabling is guarded in reverse: a plugin that others depend on cannot be deactivated while its dependents are still active.

subscription cannot be disabled while meinchat and tarot depend on it.tarot→subscription, meinchat→subscription), but core never depends on any plugin — that asymmetry is the rule the agnosticism oracle enforces.