ioc_locator 1.0.0
ioc_locator: ^1.0.0 copied to clipboard
Lightweight IoC / dependency injection service locator with sync and async factories, singletons, named instances, scopes and automatic disposal.
ioc_locator #
A lightweight, flexible IoC (Inversion of Control) service locator for Dart and Flutter.
This package was forked years ago from ioc_container, an early version of it, first for bug fixes, later it diverged significantly. This was during a time when GetIt was also unsupported for years. Handcrafted, battle-tested. But nowadays get_it is being developed again.
ioc_locator lets you register how your services are created in one place and resolve them
anywhere by type — without depending on code generation, reflection or BuildContext.
It works in plain Dart programs (CLI, server) and in Flutter apps on every platform.
Features #
- Factories and singletons — get a new instance on every lookup, or a lazily created shared one
- Async initialization — async singleton factories start at registration and run
concurrently;
await ioc.allReady()once, then resolve everything with the plainget - Parameterized factories — pass up to two typed parameters to the factory at lookup time
- Named instances — register several implementations of the same type under different names
- Scopes — create child containers with their own registrations and lifetimes
- Automatic disposal — per-service
dispose/disposeAsynccallbacks, invoked in strict reverse creation order; a throwing callback never aborts the teardown - Reference counting — share a singleton between several consumers and tear it down when the last one releases it
- Test friendly — opt-in overrides make it easy to replace services with fakes
Getting started #
Add the dependency:
dart pub add ioc_locator
# or, for a Flutter project:
flutter pub add ioc_locator
Then import it:
import 'package:ioc_locator/ioc_locator.dart';
Usage #
Creating a locator #
Create one root locator for your application. A global variable or field is fine — the locator itself is cheap and has no global state of its own:
final ioc = IocLocator();
Registering and resolving services #
abstract class WeatherRepository {
Future<double> temperature(String city);
}
class HttpWeatherRepository implements WeatherRepository {
@override
Future<double> temperature(String city) async => 21.5;
}
class WeatherService {
WeatherService(this.repository);
final WeatherRepository repository;
}
void setup() {
// A singleton: created once, on first lookup, then shared.
ioc.registerSingleton<WeatherRepository>(
(locator) => HttpWeatherRepository(),
);
// A factory: a new instance is created on every lookup.
// Dependencies are resolved through the locator passed to the factory.
ioc.register<WeatherService>(
(locator) => WeatherService(locator.get<WeatherRepository>()),
);
}
void run() {
final service = ioc.get<WeatherService>();
// A locator instance is also callable, as a shortcut for get:
final same = ioc<WeatherService>();
}
Always register against the type you resolve with — typically an abstract interface —
by specifying the type argument explicitly (registerSingleton<WeatherRepository>(...)).
If you already have the instance, register it directly:
ioc.registerSingletonInstance<AppConfig>(AppConfig.fromArgs(args));
registerOrReplaceSingletonInstance does the same but replaces (and disposes) a previously
registered singleton instead of throwing — even one that is already instantiated. It is the
one call that can hot-swap a live service. It returns a future; await it and the old
instance is disposed and the new registration is in place. A failed validation (both
disposers passed, or the key is registered as a non-singleton) throws synchronously
and leaves the existing registration untouched.
Lookup variants #
final service = ioc.get<WeatherService>(); // throws if not registered
final orNull = ioc.getOrNull<WeatherService>(); // null if not registered
if (ioc.isRegistered<WeatherService>()) {
// ...
}
getOrNull returns null only when the type is not registered. An async singleton
that has not finished initializing throws IocServiceNotReady from both lookups —
not-ready is a state error, not an absence.
Async initialization #
Some services need asynchronous initialization (opening a database, reading a file, …).
Register them with registerSingletonAsync — the factory starts running immediately,
so independent initializations run concurrently. Await allReady() once, after
registration — in main() before runApp(), or behind a splash screen — and from
then on resolve them with the plain get, like any other singleton:
ioc.registerSingletonAsync<Database>(
(locator) async => Database.open('app.db'),
disposeAsync: (db) => db.close(),
);
await ioc.allReady();
final db = ioc.get<Database>();
await ioc.isReady<T>()awaits a single service instead of all of them, rethrowing its initialization error if it failed. It completes immediately for sync registrations.ioc.allReadySync()answers the same question asallReady()as a plainbool, without waiting — handy for branching a synchronousbuild()between a splash screen and the real UI. It is a pure query: it never starts initializations and never throws; a failed initialization also reportsfalse(its error still surfaces throughget/isReady/allReady).getbefore the initialization completed throwsIocServiceNotReady.- When an async factory needs another async service, register in dependency order and await the dependency inside the factory:
ioc.registerSingletonAsync<Database>((locator) async => Database.open('app.db'));
ioc.registerSingletonAsync<HistoryRepository>((locator) async {
await locator.isReady<Database>();
return DbHistoryRepository(locator.get<Database>());
});
- After a failed initialization,
get/isReady/allReadyrethrow the factory's error;await ioc.reset<T>()re-arms the service so the nextisReady/allReadyruns the factory again.
Parameterized factories #
A factory can receive up to two typed parameters, supplied at lookup time:
ioc.registerWithParam<TcpClient, String, int>(
(locator, host, port) => TcpClient(host, port),
);
final client = ioc.get<TcpClient>(param1: 'example.com', param2: 80);
A singleton variant exists as well: registerSingletonWithParam. For a parameterized
singleton, the parameters are only used by the first lookup that creates the instance.
Named instances #
Register the same type multiple times under different instance names:
ioc.registerSingleton<Endpoint>((l) => Endpoint('https://eu.example.com'), instanceName: 'eu');
ioc.registerSingleton<Endpoint>((l) => Endpoint('https://us.example.com'), instanceName: 'us');
final eu = ioc.get<Endpoint>(instanceName: 'eu');
When you need a dynamic, collision-free name (for example one locator entry per opened document or connection), let the locator generate one:
final name = ioc.generateInstanceName<Connection>();
ioc.registerSingleton<Connection>((l) => Connection(), instanceName: name);
Disposal and lifecycle #
Every registration accepts a dispose (sync) or disposeAsync callback (at most one
of the two — both at once throws ArgumentError). The locator tracks the instances it
created (an instance singleton from its registration, an async singleton from the moment
its factory completed) and calls the callbacks when the instance is torn down:
ioc.registerSingleton<MessageBus>(
(locator) => MessageBus(),
disposeAsync: (bus) => bus.close(),
);
// Tear down everything created by this locator, in strict reverse creation order:
await ioc.dispose();
Disposal order is the exact reverse of creation order — singletons, disposable
transients and async singletons interleaved. A throwing dispose callback never aborts
a teardown: every remaining callback still runs and the container state is cleaned up,
then the failures are rethrown as a single IocDisposeException. Overlapping teardown
calls — say a fire-and-forget releaseSingleton racing dispose() — dispose each
instance exactly once.
await ioc.reset<T>()— disposes the existing instance(s) ofTbut keeps the registration, so the next lookup creates a fresh instance. An instance singleton is unregistered instead — the provided object cannot be rebuilt.await ioc.unregister<T>()— disposes the instance(s) and removes the registration.await ioc.unregisterType<T>()— likeunregister, but for every instance name registered forT.
Scopes #
A scope is a child locator that sees the parent's registrations, can add or shadow registrations locally, and can be disposed independently — useful for a screen, a request, a session or a test:
final scope = ioc.scoped();
// Visible only inside this scope; shadows any parent registration of the same type.
scope.registerSingleton<SelectionState>(
(l) => SelectionState(),
dispose: (s) => s.clear(),
);
final selection = scope.get<SelectionState>(); // scope-local
final weather = scope.get<WeatherService>(); // resolved from the parent registrations
// Disposes only the instances this scope created; the parent locator is untouched.
await scope.dispose();
By default a scope shares the singleton instances that the parent has already created,
and the parent's completed or in-flight async initializations — the same instance,
owned and disposed by the locator that created it. A scope's dispose() settles only
the initializations the scope itself started, so a slow parent factory cannot delay a
scope's teardown. Pass useExistingSingletons: false
to make the scope build its own instances from the inherited registrations instead;
its own isReady / allReady then run the async factories again for scope-owned
instances:
final sandbox = ioc.scoped(useExistingSingletons: false);
Instance singletons are shared either way: the scope receives the registered object itself, and only the locator it was registered on disposes it.
Scopes can be nested by calling scoped() on a scope.
Reference-counted singletons #
When several independent consumers share an expensive resource, reference counting tears it down exactly when the last consumer is done:
ioc.registerSingleton<DeviceMonitor>(
(locator) => DeviceMonitor(),
dispose: (m) => m.stop(),
);
// Each consumer acquires...
final monitor = ioc.acquireSingleton<DeviceMonitor>();
// ...and releases when done. The instance is disposed when the last
// acquirer releases it; the registration itself stays available.
await ioc.releaseSingleton<DeviceMonitor>();
Misuse of releaseSingleton (unknown type, more releases than acquires) throws
synchronously, so even fire-and-forget call sites (a Flutter State.dispose) catch
it. The returned future carries the disposal outcome — await it in shutdown chains,
where a throwing dispose callback surfaces as IocDisposeException; fire-and-forget,
such a callback becomes an unhandled async error (FlutterError.onError in Flutter).
Async singletons participate too: once ready they acquire like any other singleton,
while acquiring earlier throws IocServiceNotReady.
If the singleton was registered with registerSingletonInstance, releasing the last
reference also removes the registration.
Overriding registrations (testing) #
By default, registering a type twice throws. Enable overrides to allow replacing registrations — handy in tests:
final ioc = IocLocator(allowOverrides: true);
ioc.registerSingleton<WeatherRepository>((l) => HttpWeatherRepository());
ioc.registerSingleton<WeatherRepository>((l) => FakeWeatherRepository()); // replaces
A live non-disposable instance is discarded by the override: the new
registration takes effect on the next lookup. A live disposable instance
blocks it — re-registration throws IocServiceInstanceAlreadyExists — so
reset / unregister first, or hot-swap it with
registerOrReplaceSingletonInstance (and await it before the next lookup).
An instance singleton registered with a dispose callback counts as alive from
registration. Either way, install test fakes before dependents resolve and
capture the real service.
Error handling #
All errors are typed, so you can catch them precisely:
| Exception | Thrown when |
|---|---|
IocServiceNotFoundException |
Looking up a type that is not registered |
IocServiceNotReady |
Sync lookup of an async singleton that has not finished initializing — await allReady() / isReady first |
IocServiceAlreadyExists |
Registering a type twice without allowOverrides |
IocServiceInstanceAlreadyExists |
Overriding a registration whose disposable instance is still alive |
IocServiceNotRegistered |
reset / unregister / unregisterType of a type not registered in this scope; acquiring an inherited registration through a scope |
IocServiceAlreadyReleased |
releaseSingleton called more times than acquireSingleton |
IocDisposeException |
One or more dispose callbacks threw during a teardown (which still ran to completion); carries the failures |
API overview #
| Method | Instance lifetime | Resolution |
|---|---|---|
register / registerWithParam |
New instance per lookup | get / getOrNull |
registerSingleton / registerSingletonWithParam |
Lazy shared instance | get, acquireSingleton |
registerSingletonAsync |
Shared instance, initialized eagerly and concurrently | await allReady() / isReady, then get, acquireSingleton |
registerSingletonInstance / registerOrReplaceSingletonInstance |
Provided instance | get |
Skill for AI coding assistants #
The package ships an agent skill — distilled usage guidance for AI coding
assistants — in skills/flutter-ioc-locator/. With
Claude Code, copy that folder into your project's .claude/skills/ directory;
your assistant will then register, resolve, scope and dispose services with
this package correctly, and avoid the API's sharp edges.
License #
MIT — see LICENSE.