Service Registry
The ServiceRegistry lets multiple plugins compete for the same slot. Plugins register services
under a ServiceId. When someone asks for that id, the highest-priority registration wins. The loser
stays reachable if the winner wants to defer to it.
Most of the interesting behavior in Plugin Kit follows from that shape: override without patching, wrap without subclassing, discover without instantiating.
Mental model: slots
Section titled “Mental model: slots”Think of the registry as a table of slots.
- A slot has a
ServiceIdand a type. - One or more plugins can register that slot.
- The highest-priority registration wins resolution.
- The winner can still delegate to the next registration with
resolveAfter.
A slot is not a class. It is a contract the registry recognizes, and multiple implementations can satisfy it.
Three registration modes
Section titled “Three registration modes”| Method | Behavior | Use when |
|---|---|---|
registerFactory | creates a fresh instance on every resolve | service is cheap and stateless |
registerLazySingleton | creates on first resolve, then caches | setup is expensive but shared state is fine |
registerSingleton | runs the factory immediately, then caches | you want eager creation and shared state |
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(), );}If the same plugin registers the same slot twice, the second call replaces the first unless the existing registration is an attached StatefulPluginService, in which case registration throws ArgumentError.
Resolution
Section titled “Resolution”Resolve from a PluginContext, a PluginSession, or the registry directly. They all forward to the same underlying lookup.
void getLoggerDB(PluginContext context) { final db = context.resolve<Database>(const ServiceId('main_db')); final maybeLogger = context.maybeResolve<AppConfig>( const ServiceId('logger'), ); print('$db $maybeLogger');}| Method | Behavior |
|---|---|
resolve<T>(serviceId) | return the winning registration, throw if none |
maybeResolve<T>(serviceId) | return the winner or null |
resolveAfter<T>(pluginId, serviceId) | skip one registration and return the next |
resolveRaw<T>(serviceId) | return the wrapper, do not instantiate |
Priority
Section titled “Priority”If multiple plugins register the same serviceId, the registry sorts them in descending priority order. Higher number wins. The default is Priority.normal (500).
// plugin: coreregistry.registerSingleton<Formatter>( pluginId: const PluginId('core'), serviceId: const ServiceId('code_formatter'), create: () => DefaultFormatter(), priority: Priority.normal,);
// plugin: my_better_formatterregistry.registerSingleton<Formatter>( pluginId: const PluginId('my_better_formatter'), serviceId: const ServiceId('code_formatter'), create: () => PrettierFormatter(), priority: Priority.elevated,);resolve(const ServiceId('code_formatter')) now returns PrettierFormatter. DefaultFormatter is still in the registry. It is just not the winner.
Priority can also be overridden from outside by RuntimeSettings.services. The override is applied at registration time, which
means runtime settings can hand a slot to a different plugin without anyone changing source code.

The Advanced tab makes the competition visible. Every slot, every registrant, every priority, with the current winner picked out. This is the same data the runtime uses to resolve.
Delegation with resolveAfter
Section titled “Delegation with resolveAfter”resolveAfter is what turns “override” into “layer.” A plugin that wins a slot can still ask for the previous implementation and defer to it selectively.
Say your plugin provides a smart formatter for .dart files, but the user just opened a .txt file that the previous formatter already handled well.
class BetterDartFormatter extends StatefulPluginService implements Formatter { @override String format(String path, String input) { if (path.endsWith('.dart')) { // Our specialty. Format it ourselves. return input.trim(); } // Hand off to whichever Formatter would be next in line for this slot. return context.registry .resolveAfter<Formatter>(pluginId: pluginId, serviceId: serviceId) .format(path, input); }}The wrapper plugin wins the slot completely in the session but quietly defers to the runner-up winner implementation for anything it does not want to touch.
This is the clean way to extend behavior without forking the plugin you are extending.
Namespaces
Section titled “Namespaces”For related families of slots, the registry supports namespaces. Under the hood, a namespace is just a prefix: namespace.serviceId.
The registry itself only knows about ServiceId; Namespace is a tiny helper for composing dotted ids.
/// Registers a namespaced panel service and resolves it.void registerAndResolvePanel( ScopedServiceRegistry registry, PluginContext context,) { const panel = Namespace('panel');
// Build the namespaced ServiceId via call() shorthand and pass it to the // regular register / resolve methods. registry.registerSingleton<PanelWidgetFactory>( panel('console'), // ServiceId('panel.console') () => ConsolePanelFactory(), );
final factory = context.resolve<PanelWidgetFactory>(panel('console')); print(factory.name);}Namespaces are only useful for semantic grouping. Entirely stylistic. You can also register const ServiceId('panel.console') directly as a flat id and nothing breaks.
To ask “is this service id under this namespace?”, use the typed predicate namespace.has(serviceId). It matches direct children and nested descendants alike (so Namespace('panel').has(ServiceId('panel.console')) and Namespace('panel').has(ServiceId('panel.editor.terminal')) are both true).
Settings injection
Section titled “Settings injection”If the resolved instance extends PluginService, the registry injects its settings automatically during resolution. The service reads
them through a typed config wrapper instead of parsing raw maps.
class AnthropicService extends PluginService { /// The API key read from injected settings. String get apiKey => config.getString('api_key') ?? '';
/// The temperature value read from injected settings. double get temperature => config.getDouble('temperature') ?? 0.7;}For singletons and lazy singletons, the registry hashes the effective settings and only re-injects when they actually change.
Reading from config repeatedly does not trigger re-parsing.
The registry also stamps two identity fields on every resolved PluginService: pluginId (which plugin owns the registration) and serviceId (the full registry key).
These are only authoritative after resolution through the registry, so do not rely on them inside the constructor of your PluginService class.
Raw wrappers
Section titled “Raw wrappers”The registry stores RegistrationWrappers, not service instances directly. resolveRaw gives you the wrapper without instantiating anything.
/// Demonstrates resolveRaw to inspect a wrapper without instantiating.void resolveRawExample(PluginContext context) { const tooling = Namespace('tooling'); final wrapper = context.registry.resolveRaw<ModelRouter>( tooling('formatter'), ); print(wrapper.pluginId); print(wrapper.priority); print(wrapper.capabilities);}That matters when you want discovery instead of execution. Maybe you are building a settings UI that lists every configurable service without paying the cost of building them all. Maybe you want to show the user which plugin currently wins a slot. resolveRaw is how you do that without side effects.
Capabilities
Section titled “Capabilities”Capabilities are metadata tags attached to the registration wrapper, not to the instance. This allows for introspection and discovery without instantiation. The registry does not care about the content of capabilities, only that they exist on the wrapper.
void registerLinterWithCapabilities(ServiceRegistry registry) { registry.registerSingleton<CodeLinter>( pluginId: const PluginId('linter_suite'), serviceId: const ServiceId('linter'), create: () => CodeLinter(), capabilities: { const SupportsLanguages(['dart', 'js']), const PartOfASuiteOfTools('super_suite'), const CanBeSlow(true, reason: 'network round-trip per call'), }, );}Capabilities answer questions you would otherwise have to build the service to ask: does this support Dart? Is this part of a suite? You can define your own capability types. The system does not care about the set of tags, only that they exist on the wrapper.
Capabilities live on the slot registration, not inside the service instance, so you can inspect them without constructing anything. Capabilities goes deeper.