Skip to content

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.

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.

The first good migration seams are usually visible in the product.

Existing Flutter shapePlugin Kit seamWhat the user feels
a widget or controller branching between concrete providersone slot or request typeswitching implementations stops requiring UI rewrites
a model selector manually filtered by feature flags and provider checksruntime-backed winner slot plus live settingsavailable 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 itplugins can join the turn without the screen owning every phase
a customization dialog flipping booleans and then manually tearing services downupdateSettings(...)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.

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 toolOften keep it forWhere Plugin Kit pulls weight
Provider / ChangeNotifierexposing state to widgetsfeature boundaries and plugin events
Riverpodapp state, derived state, async UI stateruntime/session ownership and plugin protocols
Bloc / Cubitscreen state transitions, BlocBuilder rebuild filteringcross-feature events, request/response, plugin lifecycle
GetIt / injectorsexisting app services and legacy dependenciesreplaceable plugin services and runtime overrides
Plain managersorchestration your app already depends onextensibility 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.

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.

There are two valid ways to connect Flutter code to Plugin Kit.

StyleWhat the app knowsGood forTradeoff
Direct service accessservice IDs and service interfacesstable app services, fast migrations, plugin-provided widget factoriestighter coupling to plugin internals
Event-driven integrationevent and request classesfeature protocols, interception, streaming, multiple participants, lazy readinessyou 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.

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 timesdirect service access
a one-off actionemit<T>()
an answer from whoever can provide itrequest<TRequest, TResponse>()
optional participation from many pluginsevents handled with on<T>()
a stream of domain updatessemantic events emitted over time
plugin-provided UI factoriesdirect 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.

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 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 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 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.

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 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.

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.

  1. Leave the existing service in GetIt.

  2. Create a plugin that adapts that service into a plugin service or event handler.

  3. Move Flutter callers from GetIt.I<T>() to plugin events.

  4. 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.

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?”

  1. 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.”

  2. Add a PluginRuntime at the app, workspace, or route boundary.

    Do not scatter runtimes through child widgets.

  3. Wrap the session in your existing state-management library.

    Provider, Riverpod, Bloc, and GetIt can all hold or expose the session while you migrate.

  4. 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.

  5. Move behavior to event classes.

    The best seams are requests, commands, facts, and streamed domain updates.

  6. Add settings and runtime enablement after the seam works.

    The first win is decoupling. Configuration can follow.