inject_annotation 1.1.0-pre2
inject_annotation: ^1.1.0-pre2 copied to clipboard
Compile-time constructor-based dependency injection for Dart and Flutter, similar to Dagger.
inject.dart #
Compile-time dependency injection for Dart and Flutter.
A small, declarative DI framework with strong Flutter integration.
Annotate your classes, run build_runner, and get fully wired,
type-safe code — no reflection, no runtime lookups, no global state.
Why inject.dart? #
- Explicit dependencies. Every requirement is a constructor parameter, visible right in the class's signature. Nothing is hidden in a global registry.
- Compile-time validation. Missing bindings, cycles, and type-mismatched wiring fail at build time — not when the user opens screen 7.
- Zero runtime overhead. The generator emits plain Dart that the
compiler can fully optimise. No reflection, no
dart:mirrors. - Refactor-safe. Rename a class and the analyser shows you every
wiring affected. No magic strings, no
Typelookups at runtime. - Test-friendly. Each component is just a value. Build it with test
doubles in
setUp, throw it away intearDown. No global teardown required. - Flutter-native. The optional
inject_flutterpackage bridges generated providers to the widget lifecycle with a single typedef:ViewModelFactory<T>.
Packages #
| Package | When to depend on it |
|---|---|
inject_annotation |
Annotations and runtime types. Add it to every project. |
inject_generator |
The code generator. Add as a dev dependency. |
inject_flutter |
ViewModelFactory and friends. Add it to Flutter projects. |
Installation #
For Flutter projects:
flutter pub add inject_flutter inject_annotation dev:inject_generator dev:build_runner
For Dart projects (no Flutter integration):
dart pub add inject_annotation dev:inject_generator dev:build_runner
Quick Start (Flutter) #
We will build a counter app in four steps. The complete source lives in
examples/example/lib/main.dart.
1. Define a view model #
@inject
class CounterViewModel extends ChangeNotifier {
int count = 0;
void increment() {
count++;
notifyListeners();
}
}
@inject adds the class to the dependency graph. The generator will
construct it whenever something asks for a CounterViewModel — and as
many times as needed, because no @singleton is attached.
2. Build a widget that asks for what it needs #
class CounterPage extends StatelessWidget {
@assistedInject
const CounterPage({
@assisted super.key,
@assisted required this.title,
required this.viewModelFactory,
});
final String title;
final ViewModelFactory<CounterViewModel> viewModelFactory;
@override
Widget build(BuildContext context) {
return viewModelFactory(
builder: (context, viewModel, _) => Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(
child: Text(
'${viewModel.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
),
floatingActionButton: FloatingActionButton(
onPressed: viewModel.increment,
child: const Icon(Icons.add),
),
),
);
}
}
Two things are happening here:
@assistedInjectmarks the constructor. Parameters tagged@assistedcome from the caller at runtime (key,title); the rest is wired up by inject.dart. The factory used to invoke this constructor is synthesised by the generator — you do not have to declare it yourself.ViewModelFactory<CounterViewModel>is the Flutter bridge. It produces aViewModelBuilderthat creates a fresh view model oninitState, rebuilds the subtree onnotifyListeners(), and disposes the view model inState.dispose.
3. Wire it together with a component #
import 'main.inject.dart' as g;
part 'main.factory.dart';
void main() => runApp(
MaterialApp(
title: 'Counter',
home: AppComponent.create().counterPageFactory.create(
title: 'Counter',
),
),
);
@component
abstract class AppComponent {
static const create = g.AppComponent$Component.create;
@inject
CounterPageFactory get counterPageFactory;
}
@component declares the root of the dependency graph. The generator
produces a concrete class AppComponent$Component in main.inject.dart
that knows how to build every binding the component asks for —
including the synthesised CounterPageFactory from
main.factory.dart.
The static const create alias hides the generated class name from your
call sites. Most apps never have to import the generated files outside
of this single as g line.
4. Generate the code and run #
dart run build_runner build
flutter run
That is the entire counter app. For a slightly richer version that
also shows @module, @provisionListener, and a separate
MaterialApp widget, see examples/example/lib/main.dart.
Features #
Constructor injection — @inject #
Annotate the class (or a specific constructor) and the generator will take care of constructing it whenever something asks for that type:
@inject
class UserService {
UserService(this._client);
final http.Client _client;
}
All constructor parameters become dependencies. Inject.dart resolves
them recursively from other @inject classes or from modules.
Modules and @provides #
For types you do not own (third-party classes), or types that need
custom construction logic, declare a @module with @provides
methods:
@module
class NetworkModule {
@provides
@singleton
http.Client provideHttpClient() => http.Client();
@provides
UserService provideUserService(http.Client client) =>
UserService(client);
}
Then list the module on the component:
@Component([NetworkModule])
abstract class AppComponent { /* ... */ }
A module's @provides methods can themselves take dependencies — the
generator wires them automatically.
Shared instances — @singleton #
Apply @singleton to an @injected class or a @provides method to
guarantee that a single instance is shared across the dependency
graph:
@inject
@singleton
class SessionStore { /* ... */ }
identical(component.sessionStore, component.sessionStore) is
guaranteed to be true.
Named bindings — @Qualifier #
When two providers return the same type, distinguish them with a
@Qualifier:
const baseUrl = Qualifier(#baseUrl);
const oauthUrl = Qualifier(#oauthUrl);
@module
class UrlModule {
@provides
@baseUrl
String provideBaseUrl() => 'https://api.example.com';
@provides
@oauthUrl
String provideOAuthUrl() => 'https://auth.example.com';
}
@inject
class ApiClient {
ApiClient(@baseUrl this.baseUrl, @oauthUrl this.oauthUrl);
final String baseUrl;
final String oauthUrl;
}
A Qualifier becomes part of the binding's identity. The generator
will reject duplicates and complain at build time if you forget one.
Asynchronous providers — @asynchronous #
When a binding needs to be awaited (loading a config, opening a
database) annotate the Future-returning provider with
@asynchronous:
@module
abstract class DbModule {
@provides
@asynchronous
@singleton
Future<Database> provideDatabase() => Database.open('app.db');
}
@inject
class Repository {
Repository(this._db);
final Database _db; // Note: NOT Future<Database>
}
The @asynchronous annotation tells inject.dart to resolve the
future before providing the value. Consumers see plain Database,
not Future<Database>. The component's create method becomes
asynchronous in turn.
If you actually want to inject the Future itself, leave the
annotation off — Future<Database> is then just another type.
Runtime parameters — @assistedInject #
Some constructor parameters are only known at the call site (a widget
key, a screen title, an item id). Mark them @assisted and let
inject.dart synthesise a factory for the rest:
class DetailPage extends StatelessWidget {
@assistedInject
const DetailPage({
@assisted super.key,
@assisted required this.itemId,
required this.repository,
});
final String itemId;
final Repository repository;
// ...
}
The generator emits a corresponding factory in
<your_file>.factory.dart:
abstract class DetailPageFactory {
DetailPage create({Key? key, required String itemId});
}
You then inject the factory wherever you build the page — the
caller supplies itemId, inject.dart supplies repository.
Need a custom factory shape? Declare an
@assistedFactoryabstract class manually — e.g. to expose multiplecreatevariants, use a different method name, or constrain the factory's name. The synthesised variant is just the default for the common case.
Observing provisions — @provisionListener #
ProvisionListener is a callback that fires after every matching
dependency is created. Use it for centralised logging, metrics, or for
tracking Closeable-like resources so a test teardown can release
them in one call.
class CreationLogListener implements ProvisionListener<ChangeNotifier> {
int _count = 0;
@override
void onProvision(ChangeNotifier instance) {
_count++;
debugPrint('inject.dart -> $instance (#$_count)');
}
}
@module
class AppModule {
@provides
@singleton
@provisionListener
CreationLogListener provideListener() => CreationLogListener();
}
Three annotations work together:
@providesexposes the listener as a binding.@singletonensures the same instance observes every provisioning.@provisionListenerregisters it as a provisioning hook.
The generic parameter narrows the scope:
ProvisionListener<ChangeNotifier>fires only forChangeNotifiersubtypes.ProvisionListener<Object>(or justProvisionListener) is a catch-all.
A listener must not retain references to instances whose lifecycle is
managed elsewhere (e.g. view models owned by ViewModelFactory),
otherwise the same object could be disposed twice. The pure-observer
implementation above is safe by construction.
This feature was requested in issue #36.
Flutter integration — ViewModelFactory #
inject_flutter adds one typedef and one widget:
ViewModelFactory<T extends ChangeNotifier>— a function that returns aViewModelBuilder<T>. Inject it into your widget.ViewModelBuilder<T>— aStatefulWidgetthat:- Calls
viewModelProvider.get()ininitStateto obtain a fresh view model. - Invokes the optional
initcallback once. - Rebuilds via
ListenableBuilderwhenever the view model callsnotifyListeners(). - Calls
viewModel.dispose()inState.dispose.
- Calls
Two patterns matter:
// Default — fresh view model on initState, disposed on dispose.
viewModelFactory(
builder: (context, viewModel, _) => /* widget */,
);
// With one-shot init (e.g. trigger a network call).
viewModelFactory(
init: (viewModel) => viewModel.load(),
builder: (context, viewModel, _) => /* widget */,
);
Use the optional child parameter when part of the subtree does not
depend on the view model — ListenableBuilder will pass it through
without rebuilding.
How it works #
build_runner runs two inject.dart builders, in sequence, over
your sources. The pipeline is declared in
packages/inject_generator/build.yaml:
-
factory_builderruns first. For every.dartfile that declares@assistedInjectconstructors (or an explicit@assistedFactoryabstract class), it emits<name>.factory.dart— apartfile containing the factory contracts that the component will later implement. Files with no assisted injection get no output. -
inject_builderruns second. It picks each@Componentas a starting point and walks the dependency graph from there — pulling in@inject-annotated classes,@moduleproviders, and the factory contracts emitted in step 1. For each component it produces<name>.inject.dart, a library containing the concrete component class and every supportingProvider. Files without a@Componentget no output.
So a given source file may end up with zero, one, or both of these siblings, depending on what it contains:
| Source file declares… | Produces |
|---|---|
only @inject classes / modules |
nothing — picked up via the component |
@assistedInject constructor |
<name>.factory.dart |
@Component |
<name>.inject.dart |
| both of the above | both siblings |
What you write What the generator produces
------------------ -----------------------------
@inject _UserService$Provider
class UserService { ... } (in <component>.inject.dart)
@assistedInject DetailPageFactory (synthesised)
class DetailPage { ... } (in <name>.factory.dart, a part file)
_DetailPage$Factory (implementation)
_DetailPage$Provider (lazy)
(both in <component>.inject.dart)
@module wired into the
class NetworkModule { component's constructor
@provides ... (in <component>.inject.dart)
}
@Component([NetworkModule]) AppComponent$Component
abstract class AppComponent { (concrete class with .create)
... (in <name>.inject.dart)
}
All wiring is statically determined. The generated component
constructor instantiates every Provider in dependency order and
caches singletons in late final fields. Calling
AppComponent.create() is an O(n) operation in the size of the
graph — no allocations after that until you actually call a getter.
FAQ #
What does "compile-time" mean here? #
The dependency graph is analysed and the wiring code is generated as
part of your build. There is no runtime configuration step, no service
registration, no Type → Instance map. The output is plain Dart that
behaves like hand-written code.
Can I have more than one component? #
Yes. Components are independent values. A common pattern is one root component for the app and a per-screen or per-feature component that gets its dependencies from the root.
How do I test a class that uses inject.dart? #
You usually do not. Construct the class directly with whatever fakes
you want — every constructor parameter is explicit, so no DI framework
is involved at the unit-test level. If you want to test a whole
component, build an alternate @module that provides fakes and pass
it to the component's create method.
Development #
cd packages/inject_generator && dart test
Code-generation tests compare full generator output against golden files. To regenerate after intentional changes:
cd packages/inject_generator && UPDATE_GOLDENS=1 dart test
Or for a single test file:
cd packages/inject_generator && \
UPDATE_GOLDENS=1 dart test test/code_generation/code_generator_test.dart
Links #
- Repository: https://github.com/ralph-bergmann/inject.dart
- Issue tracker: https://github.com/ralph-bergmann/inject.dart/issues
- Documentation site: https://ralph-bergmann.github.io/inject.dart/
Contributions are welcome — feel free to open a PR.