inject_annotation 1.1.0-pre2 copy "inject_annotation: ^1.1.0-pre2" to clipboard
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 Type lookups at runtime.
  • Test-friendly. Each component is just a value. Build it with test doubles in setUp, throw it away in tearDown. No global teardown required.
  • Flutter-native. The optional inject_flutter package 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:

  • @assistedInject marks the constructor. Parameters tagged @assisted come 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 a ViewModelBuilder that creates a fresh view model on initState, rebuilds the subtree on notifyListeners(), and disposes the view model in State.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 @assistedFactory abstract class manually — e.g. to expose multiple create variants, 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:

  • @provides exposes the listener as a binding.
  • @singleton ensures the same instance observes every provisioning.
  • @provisionListener registers it as a provisioning hook.

The generic parameter narrows the scope:

  • ProvisionListener<ChangeNotifier> fires only for ChangeNotifier subtypes.
  • ProvisionListener<Object> (or just ProvisionListener) 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 a ViewModelBuilder<T>. Inject it into your widget.
  • ViewModelBuilder<T> — a StatefulWidget that:
    1. Calls viewModelProvider.get() in initState to obtain a fresh view model.
    2. Invokes the optional init callback once.
    3. Rebuilds via ListenableBuilder whenever the view model calls notifyListeners().
    4. Calls viewModel.dispose() in State.dispose.

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:

  1. factory_builder runs first. For every .dart file that declares @assistedInject constructors (or an explicit @assistedFactory abstract class), it emits <name>.factory.dart — a part file containing the factory contracts that the component will later implement. Files with no assisted injection get no output.

  2. inject_builder runs second. It picks each @Component as a starting point and walks the dependency graph from there — pulling in @inject-annotated classes, @module providers, 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 supporting Provider. Files without a @Component get 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 #

Contributions are welcome — feel free to open a PR.

21
likes
0
points
906
downloads

Documentation

Documentation

Publisher

verified publisherdasralph.de

Weekly Downloads

Compile-time constructor-based dependency injection for Dart and Flutter, similar to Dagger.

Repository (GitHub)
View/report issues

Topics

#codegen #build-runner #dependency-injection #code-generation #generator

License

unknown (license)

Dependencies

meta

More

Packages that depend on inject_annotation