Extension Bundle
Multi-component integrations at runtime without a plugin API.
The Problem
Plugin systems are infrastructure. They introduce extension-point interfaces, plugin loaders, dependency resolvers, lifecycle hooks, and version negotiation – all before a single line of business logic runs. Frameworks like OSGi, Stevedore, and WordPress plugins each define their own vocabulary for “how to extend.”
The question is: if you already have a component kernel with hot-add, an event bus for publish/subscribe, and @requires for cross-component calls, do you need any of that? The Extension Bundle pattern says no. Components are the plugin mechanism. A bundle is just a list of classes.
Architecture
The example models a core application that publishes domain events, and a Slack integration bundle that adds three components at runtime:
CoreApp SlackNotifier SlackCommandHandler
@runnable do_work @runnable notify @subscribe "core.work_completed"
| ^ |
| publish | @requires |
| "core.work_completed" | direct call |
+-----> bus ------+ +---------------------+
|
v
SlackWebhookReceiver
@runnable receive
CoreApp publishes "core.work_completed" via the bus. It knows nothing about Slack. SlackNotifier exposes a notify runnable. SlackWebhookReceiver accepts inbound webhooks and publishes "slack.webhook_received" events. SlackCommandHandler is the glue – it @subscribes to core events and calls the notifier via @requires + direct method call, bridging the two domains without either side knowing about the other.
All three are hot-added as a group and removed as a group.
How It Works
Step 1: Boot the core
The kernel starts with only the platform providers and CoreApp:
kernel = Kernel()
kernel.discover([ConfigProvider, LoggingProvider, CoreApp])
kernel.instantiate("config", properties={"defaults": {}})
await kernel.boot()At this point, the Slack components are not registered. CoreApp can publish events, but nobody is listening.
Step 2: Hot-add the bundle
The bundle is a plain Python list:
SLACK_BUNDLE = [SlackNotifier, SlackWebhookReceiver, SlackCommandHandler]Installation is a loop:
for cls in SLACK_BUNDLE:
await kernel.hot_add(cls)Each hot_add() call does the full lifecycle: register the factory, instantiate, resolve dependencies, build the reactive runtime, and activate. Order matters – SlackCommandHandler declares @requires(notifier=ISlackNotifier), so it must be added after SlackNotifier provides it.
Step 3: @subscribe wires up automatically
SlackCommandHandler declares a subscription on the core.work_completed event. The kernel registers this subscription during activation – no manual wiring:
@component("slack-commands", version="1.0")
@requires(config="IConfig", notifier="ISlackNotifier")
class SlackCommandHandler:
@subscribe("core.work_completed",
description="Auto-notify Slack when core work completes")
async def on_work_completed(self, event_type, data):
self.handled.append(("work_completed", data))
# Cross-component call: notify Slack about the work
await self.rt.notifier.notify(
channel="ops",
message=f"Work completed: {data.get('result', '?')}",
)This is the most interesting component in the bundle. It demonstrates two integration patterns at once:
- Event subscription (
@subscribe) – reacts to core domain events without CoreApp knowing it exists. - Cross-component call (
@requires+ direct method call) – calls the notifier through the injected contract, which means auth policies and audit trails apply through the contract layer.
When CoreApp.do_work() publishes "core.work_completed", the bus fans the event out to all subscribers. SlackCommandHandler.on_work_completed fires, which in turn calls the notifier via the injected contract.
Step 4: Clean removal
Removal is the mirror of installation, in reverse dependency order:
for name in ["slack-commands", "slack-webhook", "slack-notifier"]:
await kernel.hot_remove(name)Each hot_remove() unregisters subscriptions, unprovides services, releases ref counts, deactivates the component, and disposes any reactive effects. After removal, the Slack components are gone. CoreApp continues running, publishing events into the void.
Running It
PYTHONPATH=src python -m signalpy.examples.extension_bundleExpected output:
=== Core app running (no extensions) ===
No Slack components registered yet.
=== Installing Slack bundle (3 components) ===
Slack components active.
=== Core app does work -> auto-notifies Slack ===
[slack-notifier] #ops: Work completed: ok
Notifier sent: ['#ops: Work completed: ok']
Commands handled: [('work_completed', {'result': 'ok'})]
=== Direct Slack calls ===
[slack-notifier] #dev: Deploy successful
=== Removing Slack bundle ===
Slack components removed.
Extension bundle demo complete.
Tests: pytest src/signalpy/tests/test_examples.py::TestExtensionBundle -v
Production Considerations
Dependency ordering. SlackCommandHandler declares @requires(notifier=ISlackNotifier), so it must be added after SlackNotifier. In production, sort the bundle topologically before calling hot_add(). The kernel’s own _resolve_order() does this at boot – a bundle installer should reuse the same logic.
Partial install rollback. If a mid-bundle hot_add() fails, earlier components are already running. Wrap the loop with rollback:
installed = []
try:
for cls in SLACK_BUNDLE:
ci = await kernel.hot_add(cls)
installed.append(ci.name)
except Exception:
for name in reversed(installed):
await kernel.hot_remove(name)
raiseBundle manifests. SLACK_BUNDLE is a list literal. A production system would use a manifest (YAML, dataclass) declaring classes, install order, required kernel version, and config defaults. The kernel does not need to know about manifests – they are a convention on top of hot_add().
Version compatibility. Bundles that depend on specific contracts break if the contract changes. Use kernel.registry.has_provider() as a pre-check.
Key Takeaway
hot_add() + @subscribe + @requires = a plugin system. No plugin API needed. Components are the extension mechanism. The same lifecycle, dependency resolution, event routing, and reactive propagation that run at boot time also run at runtime. A bundle is just a list of component classes and a for loop.