Skip to content

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.

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 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 {}
}
MemberPurpose
pluginIdUnique identifier. Lowercase snake_case by convention. Two plugins with the same id cannot coexist in one runtime.
dependenciesSet 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.
featureFlagsBehavioral flags. Used by the runtime to set default enablement and by UI to render badges.
registerContributes services to the registry. Called once per scope on every enabled plugin before any attach runs.
attachCalled 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.
detachMirror 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, ...).
onPluginSettingsChangedCalled 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 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');
}
}
MemberPurpose
activeSubscriptionsTracked subscriptions created by service helpers. The framework cancels them automatically after detach returns.
hasContextWhether a context is currently bound (true between attach and detach).
contextThe current bound context. Throws StateError if accessed outside the attach/detach window.
attachSetup hook for subscription and startup logic while the context is bound.
detachCleanup hook that runs while context is still bound; framework cleanup runs after it returns.

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.

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');
}
FlagEffect
FeatureFlag.lockedPlugin is always enabled. Cannot be disabled via RuntimeSettings. Unmet dependencies log at severe instead of auto-disabling.
FeatureFlag.experimentalPlugin 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.

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.

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.

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.

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);
});
}
}
MemberBehavior
registrySession-scoped registry seeded with overrides from settings and populated by every enabled session plugin’s register.
busSession-scoped event bus. Disposed when the session is disposed.
contextThe domain-specific context handed to lifecycle hooks. Type K is whatever your runtime parameterizes on.
settingsThe RuntimeSettings snapshot used to create the session. Read-only; settings updates flow through the runtime’s reconciliation path.
isPluginEnabledWhether 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.
enabledPluginIdsThe unmodifiable set behind isPluginEnabled. Use for “which plugins is this session running?”
disposeDetaches every enabled plugin, disposes the session bus, removes the session from the runtime. Throws PluginLifecycleException aggregating any detach failures.
resolve / emit / onConvenience helpers via SessionHelper. Equivalent to going through registry or bus directly.

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:

  1. Instantiate the runtime with an optional plugin list. Add more later with addPlugin or addPlugins.
  2. Call init once. The runtime registers and attaches every enabled GlobalPlugin, builds globalRegistry, globalBus, and globalContext.
  3. Call createSession per session you need. Each call builds an isolated session registry and bus, registers every enabled SessionPlugin, and attaches them.
  4. Call updateGlobalSettings or updateSessionSettings to reconcile a settings change. The runtime diffs old against new, runs detach for newly disabled plugins, register/attach for newly enabled, and onPluginSettingsChanged for survivors.
  5. Call dispose once 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.

MemberBehavior
settingsThe current RuntimeSettings snapshot. Updated by init (when given a non-null settings:), updateSettings, updateSettingsSnapshot, and resetSettings.
settingsStreamBroadcast stream that emits whenever settings changes. New subscribers do not receive the current value; read settings for the latest snapshot.
enabledPlugins / enabledPluginIdsSettings-intent: plugins the current snapshot says should be on. Use for settings UI.
attachedPlugins / attachedPluginIdsRuntime-effective: plugins the runtime actually attached after dependency cascade. Use for “is it actually running.”
isPluginEnabledPass an explicit RuntimeSettings to preview, omit it to query the current snapshot.
updateSettingsFull 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.
updateSettingsSnapshotStores 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).
resetSettingsReplaces 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).

Within a scope, every enabled plugin finishes each phase before any plugin starts the next. The full sequence on a fresh scope is registerattach. The full sequence on shutdown is detach.

PhaseWhenWhat runs
registerDuring runtime.init (global) or runtime.createSession (session)plugin.register(scopedRegistry) populates the registry. No bus access yet.
attachAfter every plugin in the scope has registeredThe runtime attaches owned StatefulPluginServices, then calls plugin.attach(context). Plugins subscribe to events here.
onPluginSettingsChangedDuring updateGlobalSettings or updateSessionSettings for plugins enabled both before and afterReceives oldContext and newContext so the plugin can diff and react.
detachDuring dispose, or during a settings update for a plugin transitioning enabled to disabledThe 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.

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();
}
}
FieldMeaning
phaseName of the phase that aggregated the failures. One of attachGlobal, attachSession, detachGlobal, detachSession, updateSessionSettings, updateGlobalSettings.
failuresUnmodifiable 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.