Migrating a Flutter App
Most Flutter apps already have architecture. Good.
You might have Provider, Riverpod, Bloc, GetIt, an injector package, or a service class that slowly became the local government. Plugin Kit is not asking you to throw that away. It is the thing you add when a feature wants runtime identity: maybe it should be replaceable, isolated per session, user-toggleable, or handled by whichever plugin currently wins.
If you need the widget-side shell itself, read Flutter Integration. For library-by-library bridge code that is verified by widget tests in the example workspace, read State Management Bridges. This page is about where Plugin Kit should touch an app that already exists.
Start with one seam
Section titled “Start with one seam”Pick one place where the app currently knows too much about a concrete implementation.
Before:
GetIt.I.registerLazySingleton<SearchService>( () => AlgoliaSearchService(),);
final search = GetIt.I<SearchService>();final results = await search.query('buttons');First migration step:
void registerAllThree(ScopedServiceRegistry registry) { registry.registerFactory<QueryBuilder>( const ServiceId('query_builder'), QueryBuilder.new, );
registry.registerLazySingleton<Database>( const ServiceId('main_db'), () => Database.connect(), );
registry.registerSingleton<AppConfig>( const ServiceId('config'), () => AppConfig.load(), );}Name the plugin after the integration, not the slot. ServiceId('search')
is the generic slot; PluginId('search.algolia') is the specific
implementation occupying it. A second plugin, say ElasticSearchPlugin
with PluginId('search.elastic'), can register the same slot, and
runtime priority decides which wins.
Preferred end state for behavior seams:
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();}That is the migration in miniature: stop teaching the app which service exists, start teaching it which request it can make.
Pick seams users can actually feel
Section titled “Pick seams users can actually feel”The first good migration seams are usually visible in the product.
| Existing Flutter shape | Plugin Kit seam | What the user feels |
|---|---|---|
| a widget or controller branching between concrete providers | one slot or request type | switching implementations stops requiring UI rewrites |
| a model selector manually filtered by feature flags and provider checks | runtime-backed winner slot plus live settings | available models follow enabled plugins automatically |
| a chat orchestrator calling “gather context”, then “pick model”, then “send”, then “stream progress” | one outgoing action plus typed events around it | plugins can join the turn without the screen owning every phase |
| a customization dialog flipping booleans and then manually tearing services down | updateSettings(...) | features can turn on and off mid-session without weird leftovers |
That is the kind of result worth aiming for. The architecture matters because the product starts feeling more alive and less hard-coded.
Where Plugin Kit fits
Section titled “Where Plugin Kit fits”Plugin Kit can sit beside or above your state-management library, depending on how much you ask of the bus. Most apps keep both: the bus carries cross-feature events and plugin lifecycle, the state library exposes values to widgets. The split below is what tends to feel cheapest, not what the framework requires.
| Existing tool | Often keep it for | Where Plugin Kit pulls weight |
|---|---|---|
Provider / ChangeNotifier | exposing state to widgets | feature boundaries and plugin events |
Riverpod | app state, derived state, async UI state | runtime/session ownership and plugin protocols |
Bloc / Cubit | screen state transitions, BlocBuilder rebuild filtering | cross-feature events, request/response, plugin lifecycle |
GetIt / injectors | existing app services and legacy dependencies | replaceable plugin services and runtime overrides |
| Plain managers | orchestration your app already depends on | extensibility seams that other features can join |
Plugin Kit isn’t billed as a state-management library, but the runtime, registry, and event bus do enough that it can pass for one in a small app. The interesting question is which redundancy you want to pay for: a Cubit that listens to a bus event and re-emits it as widget state is doing the same job twice. There’s no opinion either way; the docs just don’t want to pretend the overlap isn’t there.
Put the runtime at a real boundary
Section titled “Put the runtime at a real boundary”Do not hide the runtime in a leaf widget.
Put it where your app already owns a unit of work: an app shell, document screen, workspace screen, editor screen, chat session, or route-level controller.
/// A Flutter screen that owns a [PluginRuntime] and a [PluginSession].class EditorScreenMigration extends StatefulWidget { /// Creates an [EditorScreenMigration]. const EditorScreenMigration({super.key});
@override State<EditorScreenMigration> createState() => _EditorScreenMigrationState();}
class _EditorScreenMigrationState extends State<EditorScreenMigration> { late final PluginRuntime _plugins;
PluginSession? _session;
@override void initState() { super.initState(); _plugins = PluginRuntime(plugins: [ChatPlugin(), AssistantPlugin()]); _plugins.init(); _createSession(); }
Future<void> _createSession() async { final session = await _plugins.createSession(); if (!mounted) return; setState(() => _session = session); }
@override void dispose() { _plugins.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { final session = _session; if (session == null) { return const Center(child: CircularProgressIndicator()); } return Text('session active: ${session.context.extras}'); }}That gives you one session-scoped registry and one session bus for the screen. Your Provider, Riverpod, or Bloc layer can wrap that session however it wants.
Direct access vs event contracts
Section titled “Direct access vs event contracts”There are two valid ways to connect Flutter code to Plugin Kit.
| Style | What the app knows | Good for | Tradeoff |
|---|---|---|---|
| Direct service access | service IDs and service interfaces | stable app services, fast migrations, plugin-provided widget factories | tighter coupling to plugin internals |
| Event-driven integration | event and request classes | feature protocols, interception, streaming, multiple participants, lazy readiness | you must design the event contract |
Direct access is fine when the service really is a public service contract.
For example, a shell resolving a PanelWidgetFactory is reasonable because the
shell owns the panel slot.
But for behavior like “send this message,” “run analysis,” “wait until the agent is ready,” “append streamed output,” or “let plugins prepare the prompt,” prefer events.
In practice, this is often the difference between:
- a chat screen that knows there is a context injector service, a model router, and a generation coordinator
- a chat screen that emits one typed “send this” action and then renders the events and blocks that come back
The second version is where Plugin Kit starts paying rent.
The pragmatic middle ground
Section titled “The pragmatic middle ground”Real apps rarely go from zero to perfectly event-driven in one pass.
Use this rule:
| If the caller needs… | Prefer… |
|---|---|
| a stable object it will call many times | direct service access |
| a one-off action | emit<T>() |
| an answer from whoever can provide it | request<TRequest, TResponse>() |
| optional participation from many plugins | events handled with on<T>() |
| a stream of domain updates | semantic events emitted over time |
| plugin-provided UI factories | direct service access behind a slot interface |
Do not be dogmatic. Be intentional.
If you are looking for a first “oh, this is better” moment, it is usually one of these:
- The model selector stops caring which provider plugin is enabled.
- The chat input stops orchestrating pre-send work directly.
- The settings screen can toggle a feature and trust the runtime to reconcile.
- A second workspace opens and none of the first workspace’s plugin state leaks.
Event classes become your API
Section titled “Event classes become your API”If you choose the event-driven path, the event classes are now the seam. Treat them like public API.
Good event classes are small, boring, and named after behavior:
/// A command event requesting a notification be sent.class SendNotification { /// The notification message. final String message;
/// The target channel. final String channel;
/// Creates a [SendNotification] command. const SendNotification({required this.message, required this.channel});}Avoid putting Flutter objects in them unless the event is explicitly a UI
contract. BuildContext, controllers, focus nodes, and widgets usually belong
on the Flutter side of the bridge, not inside plugin events.
If events cross a server boundary, make them serializable. For requests where
you want more than one handler to get a chance, declare the response type as
nullable. Handlers run in descending priority order (highest first); a non-null
return claims the call and stops dispatch, while returning null concedes to
the next handler. With a non-nullable response there is no cascade; the first
handler in priority order always wins.
Provider and ChangeNotifier
Section titled “Provider and ChangeNotifier”Provider can stay as your widget-tree bridge.
Let the ChangeNotifier listen to plugin events and expose widget-friendly
state. Let user actions go back into the session as events.
/// Standalone event types used in bridge examples.void showChatTypes() { const msg = ChatMessage(author: 'user', text: 'hello'); const changed = ChatMessagesChanged(messages: [msg]); const requested = SendMessageRequested(text: 'hello');
print('${msg.author}: ${changed.messages.length} ${requested.text}');}If you keep Provider, this is the cheapest split: Provider drives rebuilds and ergonomic context.watch, Plugin Kit owns the runtime protocol. Drop Provider and a setState in the shell that listens to the same bus event takes the same role with one fewer layer.
Your widgets stay boring:
class _SetStateChatScreenState extends State<SetStateChatScreen> with PluginSessionStateListener<SetStateChatScreen> { @override PluginSession? get session => widget.session;
List<ChatMessage> _messages = const <ChatMessage>[];
@override void initState() { super.initState(); listen<ChatMessagesChanged>((envelope) { if (!mounted) return; setState(() => _messages = envelope.event.messages); }); }
Future<void> _onSubmit(String text) => widget.session.emit(SendMessageRequested(text: text));
@override Widget build(BuildContext context) => ChatView(title: 'setState', messages: _messages, onSend: _onSubmit);}Riverpod
Section titled “Riverpod”Riverpod is a clean fit when you want the plugin runtime to be app-scoped and the session to be route-scoped or workspace-scoped.
final pluginRuntimeProvider = Provider<PluginRuntime>((ref) { final runtime = PluginRuntime( plugins: [ ChatPlugin(), FilesPlugin(), AnalysisPlugin(), ], ); runtime.init();
ref.onDispose(() { runtime.dispose(); });
return runtime;});
final pluginSessionProvider = FutureProvider<PluginSession>((ref) async { final runtime = ref.watch(pluginRuntimeProvider); final session = await runtime.createSession(); ref.onDispose(() => session.dispose()); return session;});Then wrap plugin events in a notifier that Riverpod widgets can watch.
class ChatController extends AsyncNotifier<List<ChatMessage>> { EventSubscription? _messagesSub;
@override Future<List<ChatMessage>> build() async { ref.onDispose(() { _messagesSub?.cancel(); });
final session = await ref.watch(pluginSessionProvider.future);
_messagesSub = session.on<ChatMessagesChanged>((envelope) { state = AsyncData(envelope.event.messages); });
return const []; }
Future<void> send(UserPrompt prompt) async { final session = await ref.read(pluginSessionProvider.future); await session.emit(SendMessageRequested(prompt)); }}Riverpod still does what Riverpod is good at: exposing async state and derived state to widgets. Plugin Kit handles feature participation behind that state.
Bloc and Cubit
Section titled “Bloc and Cubit”Bloc should stay focused on screen state.
The Cubit listens to plugin events, emits UI state, and sends user intents back to the plugin session.
class ChatCubit extends Cubit<ChatBlocState> with PluginSessionListener { final PluginSession _session;
@override PluginSession<SessionPluginContext> get session => _session;
@override List<EventBinding> get subscriptions => [ on<ChatMessagesChanged>(_onMessagesChanged), ];
ChatCubit(this._session) : super(const ChatBlocState()) { attachSubscriptions(); }
Future<void> send(String text) async { if (isClosed) return; emit(state.copyWith(sending: true)); await _session.emit(SendMessageRequested(text)); if (isClosed) return; emit(state.copyWith(sending: false)); }
void _onMessagesChanged(EventEnvelope<ChatMessagesChanged> envelope) { if (isClosed) return; emit(state.copyWith(messages: envelope.event.messages)); }
@override Future<void> close() async { detachSubscriptions(); return super.close(); }}This keeps the boundary clean:
- widgets talk to the Cubit
- the Cubit talks to Plugin Kit events
- plugins talk to each other through the bus
No widget has to resolve ChatMessagesService. No Cubit has to know which
plugin owns message storage.
That said, the Cubit-as-bridge is the most redundant of the integrations on the State Management Bridges page. Read _messagesSub’s callback: it takes a bus event and re-emits it as widget state. The bus already shipped that event; the Cubit is forwarding it. Keep the Cubit if state.copyWith ergonomics or BlocBuilder rebuild filtering pay for themselves; reach past it if not.
Waiting for readiness
Section titled “Waiting for readiness”If you find yourself reaching for “wait until X is ready,” that usually means
something upstream is not as reactive as it should be. The right shape is for
the producer to emit a readiness event once and for everyone who cares to
react to that event. No polling, no introspection, no request<WaitForX>.
/// Service that connects to an assistant in the background and broadcasts/// [AssistantReady] once the connection succeeds.class AssistantRuntimeService extends StatefulPluginService { @override void attach() { Future(() async { final assistant = await connectAssistant(); await emit(AssistantReady(assistant)); }); }}Fix the upstream design first. The work is usually small and pays off everywhere downstream.
When the producer cannot be made reactive yet
Section titled “When the producer cannot be made reactive yet”Real codebases accumulate tech debt, and not every readiness seam can be reactive on day one. If converting the producer is genuinely out of scope, the request-based fallback below is a stop on the way to the event-based version, not the destination. Treat each occurrence as a smell to track.
/// Service that lazily connects to an assistant and satisfies/// [WaitForAssistant] requests once the connection resolves.class AssistantRequestService extends StatefulPluginService { AssistantClient? _assistant; Future<AssistantClient?>? _connecting;
@override void attach() { onRequest<WaitForAssistant, AssistantClient>((_) async { return _assistant ??= await (_connecting ??= connectAssistant()); }); }}This still hides which plugin creates the assistant from the host, but the
caller is now the one driving timing, exactly the symptom that an
AssistantReady event would let you delete.
Streaming through the bus
Section titled “Streaming through the bus”Streaming does not require exposing a service just so widgets can listen to it.
If your assistant produces chunks, emit the chunks as events:
Future<void> demonstrateEmitEnvelope(PluginContext context) async { final result = await context.bus.emit<BeforeSaveEvent>( event: const BeforeSaveEvent(documentId: 'doc_1'), );
if (result.stopped) { print('Save was blocked or replaced: ${result.event}'); }
final BeforeSaveEvent payload = result.event; print('Final document ID to save: ${payload.documentId}');}A chat plugin can listen to MessageChunkReceived, update its internal state,
and emit ChatMessagesChanged. A Provider bridge, Riverpod notifier, or Cubit
can listen to ChatMessagesChanged and rebuild the UI.
For raw streams, announce the stream once and translate it into semantic events inside a plugin.
/// Emitted by a plugin that owns an open server event stream./// Other plugins listen for this to subscribe to the raw stream.class ServerEventStreamReady { /// The stream of server events. final Stream<ServerEvent> events;
/// Creates a [ServerEventStreamReady] with [events]. const ServerEventStreamReady(this.events);}
/// Typed domain event wrapping one server-side event.class ServerMessageReceived { /// The original server event. final ServerEvent event;
/// Creates a [ServerMessageReceived] wrapping [event]. const ServerMessageReceived(this.event);}
/// Plugin that bridges a raw [ServerEvent] stream into the session bus.class ServerStreamPlugin extends SessionPlugin { @override PluginId get pluginId => const PluginId('server_stream');
@override void register(ScopedServiceRegistry registry) { registry.registerSingleton<ServerStreamBridge>( const ServiceId('server_stream_bridge'), () => ServerStreamBridge(), ); }}
/// Service that listens for a [ServerEventStreamReady] event and translates/// the raw stream into typed [ServerMessageReceived] domain events.class ServerStreamBridge extends StatefulPluginService { StreamSubscription<ServerEvent>? _serverSub;
@override void attach() { on<ServerEventStreamReady>((envelope) { _serverSub?.cancel(); _serverSub = envelope.event.events.listen((serverEvent) { emit(ServerMessageReceived(serverEvent)); }); }); }
@override Future<void> detach() async { await _serverSub?.cancel(); }}The raw stream stays at the edge. The rest of the plugin system gets typed domain events.
GetIt and injector migrations
Section titled “GetIt and injector migrations”If your app is built around GetIt or an injector package, do not start by rewiring the entire application.
Start with one feature that needs runtime behavior.
-
Leave the existing service in GetIt.
-
Create a plugin that adapts that service into a plugin service or event handler.
-
Move Flutter callers from
GetIt.I<T>()to plugin events. -
Once callers stop using the GetIt registration, move construction fully into the plugin.
Adapter plugins are not a failure. They are how you migrate without turning a working app into a multi-week archaeology project.
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();}The app now talks to SearchRequested. Later, the plugin can stop using GetIt
internally and nothing outside the plugin has to care.
What should not move
Section titled “What should not move”Do not move everything into Plugin Kit.
Keep these in your existing Flutter architecture:
- route state
- widget-only animation state
- text controllers and focus nodes
- repositories that must initialise inside
main()before the runtime exists (Firebase, Adjust, crash reporters, deep-link handlers). Plugin Kit can still wrap them later, but only once you understand how their startup ordering relates to runtime and session lifecycle - simple state holders that have no plugin participation
Move these toward Plugin Kit:
- optional features
- user-toggleable features
- competing implementations
- cross-feature coordination
- behavior that should be intercepted or extended
- services that need session isolation
- protocols that should work across local, remote, and test implementations
Plugin Kit is most valuable where the app has started asking, “How do I let something else participate here without hard-coding it into this orchestrator?”
A migration checklist
Section titled “A migration checklist”-
Pick one feature seam, not one folder.
Good seams sound like events: “message sent,” “file changed,” “analysis requested,” “preview became ready,” “assistant response chunk received.”
-
Add a
PluginRuntimeat the app, workspace, or route boundary.Do not scatter runtimes through child widgets.
-
Wrap the session in your existing state-management library.
Provider, Riverpod, Bloc, and GetIt can all hold or expose the session while you migrate.
-
Start with direct service access only where it is obviously stable.
Service access is allowed. Just do not let every widget learn your plugin internals.
-
Move behavior to event classes.
The best seams are requests, commands, facts, and streamed domain updates.
-
Add settings and runtime enablement after the seam works.
The first win is decoupling. Configuration can follow.