Plugins & Lifecycle
This page covers the types you reach for when defining plugins, hosting them, or driving lifecycle transitions: Plugin, its two scoped subclasses, the contexts they receive, the PluginRuntime that owns them, and the exception thrown when a phase aggregates failures. For service registration mechanics see Service Registry & Capabilities; for event dispatch see Event Bus & Events.
PluginId
Section titled “PluginId”A zero-cost extension type wrapping a String that implements String. PluginId is the canonical identifier the runtime uses to register plugins, key service overrides, and resolve dependencies. It compiles to its underlying String, so == against a String literal works, it flows directly into any String-typed parameter or interpolation, and it can be used as a Map key with the same hash semantics as the wrapped value.
const id = PluginId('greeter');
id; // 'greeter'id == 'greeter'; // true (delegates to String equality)Use const PluginId('...') everywhere a plugin id is required. The runtime accepts no other shape.
Plugin
Section titled “Plugin”Plugin is the abstract base class. You never extend it directly: extend GlobalPlugin<G> or SessionPlugin<S> instead. Both inherit the same surface, listed here once.
abstract class Plugin { PluginId get pluginId; Set<PluginId> get dependencies => const {}; List<FeatureFlag> get featureFlags => const [];
void register(ScopedServiceRegistry registry) {}
void attach(covariant PluginContext context) {}
Future<void> detach(covariant PluginContext context) async {}
Future<void> onPluginSettingsChanged( covariant PluginContext oldContext, covariant PluginContext newContext, ) async {}}| Member | Purpose |
|---|---|
pluginId | Unique identifier. Lowercase snake_case by convention. Two plugins with the same id cannot coexist in one runtime. |
dependencies | Set of PluginIds this plugin requires. The runtime auto-disables a plugin whose dependency is not enabled. Locked plugins with unmet dependencies stay enabled and log at severe. |
featureFlags | Behavioral flags. Used by the runtime to set default enablement and by UI to render badges. |
register | Contributes services to the registry. Called once per scope on every enabled plugin before any attach runs. |
attach | Called after every plugin in the scope has registered. Subscribe to events here, not in register. Pure user hook with a no-op base implementation; do not call super.attach. The runtime attaches owned StatefulPluginServices before your hook runs, so the plugin’s attach(context) can resolve and use its own services. |
detach | Mirror of attach. Pure user hook with a no-op base implementation; do not call super.detach. After your hook returns, the runtime detaches owned services and cancels every subscription and binding registered via PluginHelper.on(context, ...) / bind(context, ...). |
onPluginSettingsChanged | Called for each plugin that ends a settings update enabled, including newly enabled plugins. Newly enabled plugins still run full register/attach, and newly disabled plugins detach. |
Equality is on (runtimeType, pluginId). A GlobalPlugin and a SessionPlugin with the same pluginId are distinct instances, but runtime registration still rejects the duplicate pluginId.
StatefulPluginService<PKC>
Section titled “StatefulPluginService<PKC>”StatefulPluginService extends PluginService and adds lifecycle hooks plus a bound context window. Use it when a service needs attach / detach, event subscriptions, or direct access to scoped context fields while attached.
class DebugAdapter extends StatefulPluginService { @override void attach() { on<DebugEvent>((e) { print('debug action: ${e.event.action}'); }); }
@override Future<void> detach() async { print('adapter shutting down'); }}| Member | Purpose |
|---|---|
activeSubscriptions | Tracked subscriptions created by service helpers. The framework cancels them automatically after detach returns. |
hasContext | Whether a context is currently bound (true between attach and detach). |
context | The current bound context. Throws StateError if accessed outside the attach/detach window. |
attach | Setup hook for subscription and startup logic while the context is bound. |
detach | Cleanup hook that runs while context is still bound; framework cleanup runs after it returns. |
GlobalPlugin<G> and SessionPlugin<S>
Section titled “GlobalPlugin<G> and SessionPlugin<S>”The two scoped subclasses. They differ only in which context they accept and when the runtime instantiates the scope. Pick GlobalPlugin for app-lifetime concerns, SessionPlugin for per-session concerns. The choice is architectural and is covered in Plugins.
class CasualPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('casual');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<Greeter>( const ServiceId('greeter'), () => CasualGreeter(), ); }}
class FormalPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('formal');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<Greeter>( const ServiceId('greeter'), () => FormalGreeter(), priority: Priority.elevated, // wins (beats Priority.normal default) ); }}
Future<void> runGreeterExample() async { final runtime = PluginRuntime(plugins: [CasualPlugin(), FormalPlugin()]) ..init(); final session = await runtime.createSession();
final greeter = session.resolve<Greeter>(const ServiceId('greeter')); print(greeter.greet('world')); // Good day, world.
await runtime.dispose();}The type parameter narrows attach and detach so the compiler rejects passing the wrong scope’s context. If your runtime uses a custom GlobalPluginContext or SessionPluginContext subtype, parameterize the plugin on that subtype and the lifecycle hooks pick it up. See Adding a Plugin for a worked example.
FeatureFlag
Section titled “FeatureFlag”A zero-cost extension type wrapping a String. Defined flags steer runtime behavior; custom flags are inert tags you can use for UI or audit purposes.
extension type const FeatureFlag(String value) { /// Plugin is locked and cannot be enabled or disabled by the user. /// /// Locked plugins are always enabled and cannot be turned off via /// [RuntimeSettings]. This is useful for critical plugins that must always be /// active for the system to function. static const locked = FeatureFlag('locked');
/// Plugin is experimental and requires opt-in. /// /// Experimental plugins are disabled by default and must be explicitly /// enabled via [RuntimeSettings]. This is useful for plugins that are /// still in development or not yet ready for general use. static const experimental = FeatureFlag('experimental');}| Flag | Effect |
|---|---|
FeatureFlag.locked | Plugin is always enabled. Cannot be disabled via RuntimeSettings. Unmet dependencies log at severe instead of auto-disabling. |
FeatureFlag.experimental | Plugin is disabled by default. Must be explicitly enabled via RuntimeSettings. |
Custom (e.g. FeatureFlag('requires_network')) | Inspected by your code or UI. The runtime ignores unknown flags. |
/// Plugin that declares multiple feature flags: experimental and a custom tag.class NetworkPlugin extends GlobalPlugin { @override PluginId get pluginId => const PluginId('network_plugin');
@override List<FeatureFlag> get featureFlags => const [ FeatureFlag.experimental, FeatureFlag('requires_network'), ];
@override void attach(GlobalPluginContext context) {}}Because FeatureFlag is a const extension type, the static constants compose naturally with Dart’s dot-shorthand syntax inside a typed list literal.
PluginContext
Section titled “PluginContext”The bundle of services and helpers handed to every plugin lifecycle hook. Domain-specific projects subclass this to carry additional state.
PluginContext makeTestContext() { // `.stub()` defaults to `ServiceRegistry.empty()` and a fresh `EventBus()`. // Pass overrides only when you need to swap in a fake. return PluginContext.stub();}To resolve a namespaced slot, build the ServiceId via Namespace.call(...) (or Namespace.service(...)) and pass it to resolve(...) or maybeResolve(...). There are no separate *Namespace helpers; the registry only knows about ServiceId.
The shorthand methods on PluginContext delegate to registry and bus. Use them or reach through to the underlying instance directly: both produce identical results.
PluginContext.stub() returns a context wired to an empty registry and a fresh bus. Use it from unit tests when you want to instantiate a PluginService or call a lifecycle hook without standing up a runtime.
GlobalPluginContext
Section titled “GlobalPluginContext”PluginContext plus a list of every active PluginSession. Global plugins use this to broadcast across sessions or to look up which session has a particular plugin enabled.
GlobalPluginContext makeTestGlobalContext() { return GlobalPluginContext.stub();}To broadcast an event to every session, use the SessionBroadcast extension on List<PluginSession>. It iterates each session and emits on its bus, which is the explicit cross-scope path documented under Runtime.
await context.sessions.emit(InvalidateCacheEvent());sessionOf throws StateError if no session has the plugin enabled. Wrap the call when you cannot guarantee an enabled session exists.
SessionPluginContext
Section titled “SessionPluginContext”PluginContext plus a reference to the global EventBus. Session plugins use it when they need to reach the global scope.
SessionPluginContext makeTestSessionContext() { return SessionPluginContext.stub();}Emit on context.bus for in-session communication, on context.globalBus to reach handlers on the global bus. There is no implicit forwarding between the two.
PluginSession<K>
Section titled “PluginSession<K>”A scoped session with its own registry, bus, and plugin attachments. The runtime constructs and tracks PluginSession instances; you receive them from PluginRuntime.createSession and dispose them when the session ends.
class GreeterPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('greeter');
@override void register(ScopedServiceRegistry registry) { registry.registerLazySingleton<GreeterService>( const ServiceId('greeter_service'), () => GreeterService(), ); }
@override void attach(SessionPluginContext context) { on<UserJoinedEvent>(context, (event) { final greeter = context.resolve<GreeterService>( const ServiceId('greeter_service'), ); greeter.sayHello(event.event.userId); }); }}| Member | Behavior |
|---|---|
registry | Session-scoped registry seeded with overrides from settings and populated by every enabled session plugin’s register. |
bus | Session-scoped event bus. Disposed when the session is disposed. |
context | The domain-specific context handed to lifecycle hooks. Type K is whatever your runtime parameterizes on. |
settings | The RuntimeSettings snapshot used to create the session. Read-only; settings updates flow through the runtime’s reconciliation path. |
isPluginEnabled | Whether the plugin is currently active in this session. At session scope the dependency cascade has already resolved by the time the session exists, so the “enabled” set IS the “actually attached” set. Tracked separately from registry contents because some plugins only register tools. |
enabledPluginIds | The unmodifiable set behind isPluginEnabled. Use for “which plugins is this session running?” |
dispose | Detaches every enabled plugin, disposes the session bus, removes the session from the runtime. Throws PluginLifecycleException aggregating any detach failures. |
resolve / emit / on | Convenience helpers via SessionHelper. Equivalent to going through registry or bus directly. |
PluginRuntime<G, S>
Section titled “PluginRuntime<G, S>”The lifecycle engine. Owns the global scope, creates sessions, runs reconciliation. Generic over the global and session context types: G extends GlobalPluginContext and S extends SessionPluginContext. Use the defaults when you do not need custom fields; otherwise supply globalContextFactory to init and contextFactory to createSession so the runtime can construct your subtype.
class PluginRuntime<G extends GlobalPluginContext, S extends SessionPluginContext> { PluginRuntime({List<Plugin>? plugins});
late final ServiceRegistry globalRegistry; late final EventBus globalBus; late final G globalContext;
List<Plugin> get plugins; List<GlobalPlugin> get globalPlugins; List<SessionPlugin> get sessionPlugins; List<PluginSession<S>> get sessions;
RuntimeSettings get settings; Stream<RuntimeSettings> get settingsStream;
void addPlugin(Plugin plugin); void addPlugins(List<Plugin> plugins);
PluginRuntime init({ RuntimeSettings? settings, GlobalContextFactory<G, S>? globalContextFactory, UnknownReferencePolicy unknownReferencePolicy = UnknownReferencePolicy.throwError, });
Future<PluginSession<S>> createSession({ RuntimeSettings? settings, SessionContextFactory<G, S>? contextFactory, });
Iterable<Plugin> get enabledPlugins; Set<PluginId> get enabledPluginIds; List<Plugin> get attachedPlugins; Set<PluginId> get attachedPluginIds; bool isPluginEnabled(PluginId pluginId, [RuntimeSettings? settings]); bool isPluginAttached(PluginId pluginId);
Future<void> updateSettings(RuntimeSettings newSettings); void updateSettingsSnapshot(RuntimeSettings value); void resetSettings();
Future<void> updateGlobalSettings({ required RuntimeSettings oldSettings, required RuntimeSettings newSettings, });
Future<void> updateSessionSettings( PluginSession<S> session, { required RuntimeSettings newSettings, });
Future<void> dispose();}Order of operations:
- Instantiate the runtime with an optional plugin list. Add more later with
addPluginoraddPlugins. - Call
initonce. The runtime registers and attaches every enabledGlobalPlugin, buildsglobalRegistry,globalBus, andglobalContext. - Call
createSessionper session you need. Each call builds an isolated session registry and bus, registers every enabledSessionPlugin, and attaches them. - Call
updateGlobalSettingsorupdateSessionSettingsto reconcile a settings change. The runtime diffs old against new, runs detach for newly disabled plugins, register/attach for newly enabled, andonPluginSettingsChangedfor survivors. - Call
disposeonce at shutdown. The runtime detaches global plugins, disposes every session, and disposes the global bus.
Plugin enablement falls back through three rules: FeatureFlag.locked plugins are always on, then an explicit RuntimeSettings.plugins entry wins, then FeatureFlag.experimental plugins default off while all other plugins default on. Use explicit RuntimeSettings.plugins entries to disable stable plugins or enable experimental ones, then pass settings to init (or to updateSettings later). Locked plugins stay enabled regardless of explicit settings.
globalContextFactory is required when G is a custom subtype of GlobalPluginContext. The default factory builds a base GlobalPluginContext, which cannot be cast to a tighter type. Same rule for contextFactory on createSession when S is custom.
unknownReferencePolicy controls how the runtime responds when RuntimeSettings references a plugin or service id this runtime does not know about (typo, renamed id, or cached settings written by a prior app version). Defaults to UnknownReferencePolicy.throwError: the base package stays strict so drift is loud during development and CI. Production load paths that read cached settings should pass UnknownReferencePolicy.logAndSkip so renamed ids do not crash startup; UnknownReferencePolicy.ignore silently drops the unknown entry. The policy is set once on init and is reused by every createSession / updateSessionSettings / updateGlobalSettings call on the same runtime. Three validation passes run at each entry point: plugin ids in RuntimeSettings.plugins, plugin ids in service pin keys, and (after register-all) service ids in plugin-scoped pins. Wildcard pins are exempt from the service-id pass.
isPluginEnabled answers “would this plugin be enabled under these settings?” without applying dependency resolution. Use it for previews and UI tooling.
When attach throws inside init, createSession, or a settings update, the runtime collects every failure and throws PluginLifecycleException after the loop completes. Plugins that did not throw remain attached.
Settings storage and reconciliation
Section titled “Settings storage and reconciliation”| Member | Behavior |
|---|---|
settings | The current RuntimeSettings snapshot. Updated by init (when given a non-null settings:), updateSettings, updateSettingsSnapshot, and resetSettings. |
settingsStream | Broadcast stream that emits whenever settings changes. New subscribers do not receive the current value; read settings for the latest snapshot. |
enabledPlugins / enabledPluginIds | Settings-intent: plugins the current snapshot says should be on. Use for settings UI. |
attachedPlugins / attachedPluginIds | Runtime-effective: plugins the runtime actually attached after dependency cascade. Use for “is it actually running.” |
isPluginEnabled | Pass an explicit RuntimeSettings to preview, omit it to query the current snapshot. |
updateSettings | Full reconciliation: runs updateGlobalSettings, then updateSessionSettings for every active session sequentially in insertion order, then publishes the new snapshot. A failure in any phase aborts and preserves the prior snapshot. |
updateSettingsSnapshot | Stores and publishes the new snapshot without any lifecycle work. Use when listeners need to see the change but the runtime has already converged (or you intend to converge it later). |
resetSettings | Replaces stored settings with RuntimeSettings(). No reconciliation. |
/// Shows the two settings-update modes: full reconciliation via/// [PluginRuntime.updateSettings] and publish-only via/// [PluginRuntime.updateSettingsSnapshot].Future<void> demonstrateUpdateModes(PluginRuntime runtime) async { final newSettings = runtime.settings.copyWith( plugins: { ...runtime.settings.plugins, const PluginId('analytics'): const PluginConfig(enabled: false), }, );
await runtime.updateSettings(newSettings); // full reconcile
final snapshot = runtime.settings.copyWith(); runtime.updateSettingsSnapshot(snapshot); // publish without reconciling}The two update modes solve different problems. updateSettings is the normal path: it converges the live runtime on a new state. updateSettingsSnapshot is for cases where listeners need to see a snapshot but the runtime already reflects it (replaying a saved draft into the UI, for example).
Plugin lifecycle phases
Section titled “Plugin lifecycle phases”Within a scope, every enabled plugin finishes each phase before any plugin starts the next. The full sequence on a fresh scope is register → attach. The full sequence on shutdown is detach.
| Phase | When | What runs |
|---|---|---|
register | During runtime.init (global) or runtime.createSession (session) | plugin.register(scopedRegistry) populates the registry. No bus access yet. |
attach | After every plugin in the scope has registered | The runtime attaches owned StatefulPluginServices, then calls plugin.attach(context). Plugins subscribe to events here. |
onPluginSettingsChanged | During updateGlobalSettings or updateSessionSettings for plugins enabled both before and after | Receives oldContext and newContext so the plugin can diff and react. |
detach | During dispose, or during a settings update for a plugin transitioning enabled to disabled | The runtime calls plugin.detach(context), detaches owned services, cancels tracked subscriptions, and clears the bound context. |
The practical rule: anything that depends on another plugin belongs in attach. At register time, peers may not have registered yet.
PluginLifecycleException
Section titled “PluginLifecycleException”Thrown when one or more plugins fail during a lifecycle phase. The runtime continues processing remaining plugins after a throw, then throws this exception with every collected failure once the loop completes.
class PluginLifecycleException implements Exception { /// The lifecycle phase where failures occurred (e.g. `'attachGlobal'`). final String phase;
/// The list of plugin failures: `(pluginId, error, stackTrace)`. final List<(PluginId pluginId, Object error, StackTrace stackTrace)> failures;
/// Creates a lifecycle exception for [phase] with collected [failures]. PluginLifecycleException( this.phase, List<(PluginId, Object, StackTrace)> failures, ) : failures = List.unmodifiable(failures);
@override String toString() { final buffer = StringBuffer( 'PluginLifecycleException: ${failures.length} plugin(s) failed during $phase:\n', ); for (final (pluginId, error, _) in failures) { buffer.writeln(' - $pluginId: $error'); } return buffer.toString(); }}| Field | Meaning |
|---|---|
phase | Name of the phase that aggregated the failures. One of attachGlobal, attachSession, detachGlobal, detachSession, updateSessionSettings, updateGlobalSettings. |
failures | Unmodifiable list of (pluginId, error, stackTrace) records, one per plugin that threw. |
Future<void> handleLifecycleException() async { final runtime = PluginRuntime(plugins: [CrashingPlugin()]);
try { runtime.init(); } on PluginLifecycleException catch (e) { print('Phase: ${e.phase}'); for (final (pluginId, error, _) in e.failures) { print(' $pluginId failed: $error'); } }}The runtime never swallows lifecycle failures. If you want best-effort disposal that does not surface errors, catch PluginLifecycleException at the call site and log; the runtime has already finished its work by the time it throws.