Skip to content

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.

Think of the registry as a table of slots.

  • A slot has a ServiceId and 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.

MethodBehaviorUse when
registerFactorycreates a fresh instance on every resolveservice is cheap and stateless
registerLazySingletoncreates on first resolve, then cachessetup is expensive but shared state is fine
registerSingletonruns the factory immediately, then cachesyou 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.

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');
}
MethodBehavior
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

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: core
registry.registerSingleton<Formatter>(
pluginId: const PluginId('core'),
serviceId: const ServiceId('code_formatter'),
create: () => DefaultFormatter(),
priority: Priority.normal,
);
// plugin: my_better_formatter
registry.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.

Plugin Kit Dialog Advanced tab showing competing service registrations with priority order and the current winner highlighted

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.

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.

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

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.

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