Lean DI
A service-locator-based dependency injection solution for Dart, with scopes, modules, computed dependencies, and reactivity support.
This documentation is for plain Dart usage of Lean DI. For usage in Flutter see
flutter_lean_di.
Installation
dart pub add lean_di
Quick start
import 'package:lean_di/lean_di.dart';
void main() {
Deps.global.add(Dependency.value(Greeter()));
final greeter = Deps.global.get<Greeter>();
greeter.greet();
}
class Greeter {
void greet() => print('Hello world!');
}
Deps.global is the global, ambient Deps container - a box you register
services into and look them up from anywhere, by type. Everything below builds
on that one idea: add(...) to register, get<T>() to look up.
A note on
create: everyDependency'screatecallback takes two positional parameters -(DepsReader deps, T? oldValue).oldValueis only relevant for the "computed dependency" recipe below; for a plain one-shot factory, just ignore it ((deps, _) => ...).
Common recipes
Registering a dependency
// A value that already exists and never changes.
Deps.global.add(Dependency.value(SomeService()));
// Lazily created on first use.
Deps.global.add(Dependency((deps, _) => SomeService()));
// With cleanup when removed/replaced/the scope is disposed.
Deps.global.add(
Dependency(
(deps, _) => SomeService(),
dispose: (service) => service.dispose(),
),
);
// Eagerly created on registration - once the whole add()/addAll() call has
// registered everything, so it can read dependencies registered with it.
Deps.global.add(Dependency((deps, _) => SomeService(), lazy: false));
// Depending on another registered service.
Deps.global.add(
Dependency(
(deps, _) => SomeOtherService(deps.get<SomeService>()),
),
);
Reading a dependency
// Returns an instance of the dependency, creating it first if needed. Throws
// DependencyNotRegisteredError if the dependency has not been registered.
SomeService service = Deps.global.get<SomeService>();
// Same, but returns null instead of throwing if dependency has not been
// registered.
SomeService? service = Deps.global.tryGet<SomeService>();
// Returns an instance of the dependency if it has been already created. Returns
// null if instance of the dependency has not been created yet and doesn't
// trigger creation. Returns null if dependency has not been registered.
SomeService? service = Deps.global.peek<SomeService>();
Watching for changes
// Fires whenever a *new instance* is registered under this type (add()
// with a changed cacheKey, or replace()) - not on that instance's own
// internal state changes. Cheap: never subscribes to the value itself.
Stream<SomeService> instances = Deps.global.watchInstance<SomeService>();
// Fires on registration changes AND on internal state changes reported by
// the resolved value's DependencyObserver, if it has one - see "Observing
// internal state changes" below.
Stream<SomeService> full = Deps.global.watch<SomeService>();
// Combine several dependencies - handy for a widget/effect that needs more
// than one. watch2..watch5 cover 2-5 types; they use watchInstance
// semantics, not watch, for each.
Deps.global.watch2<UserService, SettingsService>().listen((values) {
final (user, settings) = values;
// ...
});
Computed dependencies
A dependency can recompute its value in reaction to another one changing -
create is simply called again, with the previous value as oldValue,
whenever a key it read via deps.watchInstance(...) (not the untracked
deps.get(...)) on its last run gets re-registered under a new instance.
There's no separate list of "what to observe" to keep in sync - reading via
watchInstance inside create is the declaration, rediscovered fresh on
every run:
Deps.global.add(
Dependency<UserGreeting>((deps, oldValue) {
final user = deps.watchInstance<UserService>().currentUser;
return oldValue == null
? UserGreeting('Hello, ${user.name}!')
: oldValue.copyWith(text: 'Hello, ${user.name}!');
}),
);
Which keys are tracked can even depend on oldValue, so a create that
takes a different branch on a later run automatically re-subscribes to
whatever it read this time, dropping keys it no longer reads.
Registering the same instance under another type
Useful for exposing a concrete implementation through an interface, so either can be looked up:
Deps.global.addAll([
Dependency<SomeServiceImpl>((deps, _) => SomeServiceImpl()),
const Alias<SomeService, SomeServiceImpl>(),
]);
Deps.global.get<SomeService>() == Deps.global.get<SomeServiceImpl>(); // true
The alias is lazy, stays in sync with a later replace<SomeServiceImpl>(...),
and never disposes anything itself - only SomeServiceImpl's own dispose
(if any) tears the shared instance down. watch<SomeService>() does
not react to the shared instance's internal state changes by default -
SomeService declares nothing about being observable, so nothing is
assumed; pass createObserver to Alias explicitly if you want that.
One caveat worth knowing: because this is sugar for a second, independent
registration, removing only SomeServiceImpl (bare
remove<SomeServiceImpl>()) leaves the SomeService key registered
and pointing at what's now a disposed instance. Register and remove them
together - e.g. via a Module (see below), or by removing both keys
explicitly.
Observing internal state changes
Beyond "a new instance was registered," a dependency can report its own
internal state changes (e.g. a mutable object notifying its listeners) so
watch<T>() reacts to those too, not just registration:
Deps.global.add(
Dependency<Counter>(
(deps, _) => Counter(),
createObserver: (counter) => CounterObserver(counter),
),
);
Deps.global.watch<Counter>().listen((counter) => print('now: ${counter.value}'));
class CounterObserver extends DependencyObserver<Counter> {
CounterObserver(this.counter) {
counter.addListener(_onChange);
}
final Counter counter;
void _onChange() => notifyStateChanged();
@override
Future<void> dispose() async => counter.removeListener(_onChange);
}
One observer is created per resolved value (shared by every subscriber),
and only once something actually calls watch() - a dependency that's
only ever plain-read via get() never pays for this. If you're on Flutter,
flutter_lean_di ships
ChangeNotifierDependency/ListenableDependency for ChangeNotifier
/Listenable values, and
flutter_lean_di_bloc
ships BlocDependency for bloc/cubit - you rarely need to write a
DependencyObserver by hand outside plain Dart code.
Scoping
Deps instances form a tree: a child scope (fork) inherits everything
its ancestors have registered, and can override specific keys locally
without touching the parent. Changes in an ancestor (a new registration, a
replacement, an internal state change) propagate down to every descendant
that hasn't shadowed that key itself.
final authScope = Deps.global.fork(debugLabel: 'AuthScope');
authScope.add(Dependency<AuthToken>.value(AuthToken('...')));
authScope.get<AuthToken>(); // registered directly in authScope
authScope.get<SomeGlobalService>(); // inherited from Deps.global (the parent)
await authScope.dispose(); // disposes only what authScope itself registered
Deps() creates a scope with no parent at all - useful for tests,
so each test gets a clean, isolated container instead of sharing
Deps.global.
Grouping dependencies
A Module bundles several dependencies so they're added and removed
together as one unit:
final authModule = Module([
Dependency<AuthService>((deps, _) => AuthService()),
Dependency<TokenStorage>((deps, _) => TokenStorage()),
], debugLabel: 'Auth');
Deps.global.add(authModule);
// ...
Deps.global.remove(authModule); // removes both AuthService and TokenStorage
remove(...) also accepts a fresh Module/Dependency describing
the same types - lookup is by type, not by holding onto the exact instance
that was originally registered.
Startup dependencies via tags
You can use tags for more complex creation orchestration scenarios than the
eager creation on registration provided by lazy: false. Tag dependencies
that need to be ready at some point, and create them all when needed:
void main() {
Deps.global
..add(Dependency<Database>((deps, _) => Database(), tags: const ['startup']))
..add(Dependency<Analytics>((deps, _) => Analytics(), tags: const ['startup']));
// ...
final startupKeys = Deps.global.getEntriesWithTag('startup').map((d) => d.key);
Deps.global.ensureResolved(startupKeys); // synchronous - forces creation now
startServer();
}
Removing and replacing
final unregister = Deps.global.add(Dependency<Foo>((deps, _) => Foo()));
unregister(); // equivalent to remove<Foo>()
// add() again under the same key/cacheKey keeps the existing value, but the
// entry takes on the new dependency - its lazy flag, and the create/dispose
// used from then on. replace() always tears down and recreates.
Deps.global.replace(Dependency<Foo>((deps, _) => Foo()));
Error handling
| Error | Thrown by | When |
|---|---|---|
DependencyNotRegisteredError |
get |
Nothing is registered under that key in this scope or its ancestors. |
DepsDisposedError |
add, get, tryGet |
The scope has already been disposed. |
DependencyCycleError |
get, tryGet (on creation) |
create re-enters resolving its own key before finishing. |
isRegistered<T>() and tryGet<T>() are the ways to check whether a
dependency is registered without get throwing.
An error thrown by the create of an eager (lazy: false) dependency isn't
thrown from add/addAll - it's reported to the current zone's error
handler, the other dependencies are still registered and created, and the
failed one is created again on its next get.
See also
flutter_lean_di- Flutter integration: aDepsProviderwidget,context.get/watch/watchInstance/select, and Flutter-flavoredDependencysubclasses.flutter_lean_di_bloc-bloc/cubitintegration on top offlutter_lean_di.
π οΈ Maintained by LeanCode
This package is built with π by LeanCode. We are top-tier experts focused on Flutter Enterprise solutions.
Why LeanCode?
-
Creators of Patrol β the next-gen testing framework for Flutter.
-
Production-Ready β We use this package in apps with millions of users.
-
Full-Cycle Product Development β We take your product from scratch to long-term maintenance.

