Developer Docs · 03

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 admin Backend Plugins list — each plugin's version, Active/Inactive status and the Enable/Disable lifecycle action.
The admin Backend Plugins list — each plugin's version, Active/Inactive status and the Enable/Disable lifecycle action.

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's PluginMetadata (name, version, dependencies). Nothing runs yet.
  • initializedinitialize(config) merges the plugin's DEFAULT_CONFIG with the stored config. Still no side effects — just settled configuration.
  • enabledon_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 marked enabled: true in plugins.json reach this state.
  • disabledon_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 schema

Registration

  • Add the plugin to plugins/plugins.json (enabled: true) and its config to plugins/config.json.
  • Put migrations in plugins/<name>/migrations/versions/ — they are auto-discovered by alembic/env.py (no alembic.ini edit 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_enable never 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-plugins flags pull in every transitive dependency automatically: name referral and it brings discount and meinchat along.

The admin surfaces the resolved graph per plugin — each dependency with its required version specifier, the installed version, and a satisfied / blocked status:

The referral plugin's dependency table — discount and meinchat, both satisfied.
A plugin detail page — the Dependencies table resolves each declared dependency (required specifier, installed version, satisfied status), alongside the Activate / Deactivate control.

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

A bot plugin depending on seven other plugins, all satisfied.
A larger dependency graph — the resolver checks every declared peer before the plugin may be enabled.

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 activation blocked — subscription>=26.7 required but 26.6 installed.
Version too old — 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:

HTTP 422 — cannot enable cms-ai: requires cms>=26.7 but cms v26.6 installed.
The server-side gate — 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.

Cannot disable subscription — meinchat and tarot depend on it.
Reverse-dependency protection — subscription cannot be disabled while meinchat and tarot depend on it.
Dependencies are declared, not imported. A plugin may depend on another plugin (tarotsubscription, meinchatsubscription), but core never depends on any plugin — that asymmetry is the rule the agnosticism oracle enforces.