Event Patterns
This page is the patterns side of the event bus: what to reach for from inside plugin code, and how to shape an event type.
The model itself (envelope, priority direction, identifier scoping, cross-scope routing, request/response dispatch) lives one page over in Event Bus. Read that first if you want the mechanics. This page assumes you already have them.
What events buy you in a real app
Section titled “What events buy you in a real app”Events are how Plugin Kit graduates from “service registry with nicer manners” to “a runtime the product can feel.”
- A send button can emit one draft action, and plugins can inspect it, enrich it, block it, or stream progress back into the same message bubble.
- A feature can ask “who can answer this?” through a typed request instead of resolving a concrete service and hoping it is ready.
- A shell can emit a collection event like
CollectPanelsorCollectToolbarItems, and enabled plugins can append to the result in order. - A widget can stay dumb while plugins add richer behavior around it: context gathering, routing, logging, analytics, moderation, fallback, or whatever your app needs next year.
The idea is to separate your application from plugins even further. Your pretty abstraction layer is great with registry.resolve(), but what if your app didn’t even need to know about the registry and the services at all? What if the app just emitted events blindly and let plugins do the rest internally?
Other reasons you will want to reach for events: inter-plugin communication without direct dependency.
That is the vision for an event-driven plugin architecture, and it is what Plugin Kit supports.
Designing an event type
Section titled “Designing an event type”Events are just Dart types.
/// An event emitted after the user logs in.class UserLoggedIn { /// The user identifier. final String userId;
/// Creates a [UserLoggedIn] event. const UserLoggedIn({required this.userId});}Two conventions that pay off over time:
- Keep normal events immutable. For facts, commands, and requests, make the fields
finaland create a new object when you need a variant. - Name the verb deliberately. Past tense for things that already happened (
UserMessageReceived,DocumentSaved,PaymentRejected). Imperative for request types (SendNotification,WaitForAssistant). The full naming guide lives in Naming Conventions.
The main exception is a deliberate pre-commit draft object emitted so handlers can rewrite it before some outside effect happens. The “pre-commit interception” section below covers when that exception is right and how to name it.
Subscribing from a service
Section titled “Subscribing from a service”Inside a StatefulPluginService.attach(), use the service helpers instead of reaching into
context.bus. They track subscriptions for you and cancel them on detach automatically.
class ConversationState extends StatefulPluginService { @override void attach() { on<UserMessage>((e) async { final memory = resolve<ConversationMemory>(const ServiceId('memory')); memory.append(e.event.text); await emit(MessageStored(e.event.text)); }); }}The three tracked subscription helpers:
| Helper | For |
|---|---|
on<T>() | event handlers; observe, mutate, or stop the cascade |
onRequest<Req, Res>() | async typed request handlers |
onRequestSync<Req, Res>() | synchronous typed request handlers |
Every on handler receives an EventEnvelope<T>. What the handler does with it decides the style:
- Observe: read
e.event, do side effects (log, count, update UI), return. The cascade keeps going. - Participate: mutate
e.eventin place so downstream handlers see the change, or calle.stop(value)to halt the cascade and pin the final result.
There is no separate API to opt into participation; the capability is always there in the envelope. If a handler does not need to mutate or stop, it simply does not.
Subscribing from a plugin
Section titled “Subscribing from a plugin”You can subscribe directly from SessionPlugin.attach(...). After importing plugin_kit, Plugin itself
gains the same auto-tracking helpers stateful services use: on<T>(), onRequest<Req, Res>(),
onRequestSync<Req, Res>(), and bind(). Subscriptions and bindings are tracked per context and
torn down automatically when the plugin detaches from that context. Plugin.attach and
Plugin.detach are pure user hooks: no super call is needed. The framework runs orchestration
(stateful service attach/detach, subscription cleanup) around your hook.
Plugin helpers take context as the first positional argument, since plugins are shared across sessions.
This shape is the right home for trivial wiring: a one-off debug log, a tracing tap, a small bridge between two services the same plugin owns. The auto-tracking means you do not have to remember the cleanup step, and the call site stays in the plugin class instead of growing a service for a single line.
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); }); }}Reach for context.bus.on(...) only when you genuinely need to manage a subscription’s lifetime
by hand: the bus call returns a EventSubscription you have to cancel yourself, and is not tracked.
Fire and await
Section titled “Fire and await”From inside a stateful service, emit(...) is the short form. It sends the event on the session
bus and completes when all handlers have run.
await emit(UserMessageReceived( sessionId: currentSession, text: 'Hello',));This is the right form for facts and one-way notifications. Most events you emit will look like this.
The pre-commit interception pattern
Section titled “The pre-commit interception pattern”context.bus.emit<T>(...) returns an EventEnvelope<T>. You use this form when you want
to let other plugins interfere with the event before you commit to it.
You emit what you intend to do. Handlers get a chance to rewrite the payload, enrich it, redact it, or veto the whole thing. You read the envelope back and act on whatever the chain produced. It is how you give plugins a seat at the table without calling them by name.
This is also the main exception to the immutability rule above: the payload is intentionally a draft object that handlers are allowed to edit in place.
/// A mutable draft event for outgoing messages, allowing handlers to mutate/// or veto the payload before it is sent.class DraftOutgoingMessage { /// The current text of the draft, mutable by handlers. String text;
/// Metadata attached by handlers. final Map<String, String> metadata;
/// Creates a [DraftOutgoingMessage] with [text]. DraftOutgoingMessage(this.text) : metadata = {};}A handler can do two things to the payload:
- Mutate the draft in place. Append context, redact PII, rewrite URLs, translate text.
Return without calling
stopand later handlers plus the emitter see the mutation. The cascade keeps running. - Replace or veto by calling
e.stop(value). Ends the cascade right there, pinse.eventtovalue, and flipse.stoppedtotrue. No more handlers run.
Whichever path the handler takes, the envelope the emitter gets back is always the after view.
envelope.event is the final payload. envelope.stopped tells you whether the cascade was cut short.
envelope.identifier carries through whatever identifier the event was emitted under.
The pre-commit interception pattern is a contract between emitter and handlers: the emitter promises to honor whatever the chain produced, so plugins can change or block the event before it lands without the emitter having to call them by name.
Requests and responses, from a plugin
Section titled “Requests and responses, from a plugin”The bus also does typed request/response. The mechanics are covered in Event Bus; here is the shape inside plugin code.
Future<void> demonstrateMultiSessionIsolation(PluginRuntime runtime) async { final sessionA = await runtime.createSession(); final sessionB = await runtime.createSession();
// Each session has its own bus; events do not cross. sessionA.bus.on<AppThemeChanged>((e) => print('A: ${e.event.theme.name}'));
await sessionA.bus.emit<AppThemeChanged>( event: const AppThemeChanged(Theme(name: 'dark')), ); // Session B's subscriber never fires.
await sessionA.dispose(); await sessionB.dispose();}Callers ask through the bus:
final port = await context.bus.request<FindOpenPort, int?>( const FindOpenPort(),);The two patterns worth remembering when designing a request type:
- Make
Responsenullable.return nullmeans “I concede; let the next handler try.” That is how a chain of handlers falls through until one provides an answer. A non-nullable response forces every handler to win or throw. - Match
requesttomaybeRequestbased on caller expectations.requestthrows when nothing answers;maybeRequestreturnsnull. Pick the one that matches what the call site actually wants to do on a missed handler.
Common pitfalls
Section titled “Common pitfalls”- Reading the envelope as if it were the payload. The callback gets
EventEnvelope<T>, so the payload lives ate.event, note. Accessing fields directly on the callback argument will not compile. - Forgetting the
contextargument onPluginhelpers.Plugin.on,onRequest,bind, andemittakecontextas the first positional arg (on<E>(context, (e) => ...)). Inside aStatefulPluginServicethe helpers readthis.contextinstead, so the same call ison<E>((e) => ...). Mixing the two shapes is the easy mistake. - Non-nullable
Responsetypes. Request chains depend onnullmeaning “concede.” If every handler must return a value, you forfeit the fall-through pattern. - Leaning on
context.bus.oninside a plugin or stateful service. You lose auto-tracking. Use theon(...)/onRequest(...)/onRequestSync(...)helpers; bothPluginandStatefulPluginServiceexpose them after importingplugin_kit. - Putting real behavior in
Plugin.attach. It works and the cleanup story is fine, but a handler attached on the plugin is bound to the plugin’s enabled state and cannot be overridden, prioritized, or tuned viaRuntimeSettings.services. Only services live in the registry, and only services participate in priority/override. Use plugin-level subscriptions for trivial wiring; put anything you’d want to be replaceable in aStatefulPluginService.