Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Quickstart

This chapter builds a counter app the way flutter_demo does — a small, layered Flutter app that follows Flutter’s recommended architecture. Every snippet is taken from that example; open the linked files alongside this chapter for the complete, running source.

By the end you will know how to:

  • add inject.dart to a Flutter project
  • annotate classes and provide third-party types with a module
  • expose runtime-parameterised widgets with @assistedInject
  • declare a component and run the generator
  • use the wired-up component in main()

Create the project

flutter create counter_app
cd counter_app
flutter pub add inject_annotation inject_flutter dev:inject_generator dev:build_runner

(Chapter 2 covers the packages and their roles in detail.)

The concepts

inject.dart wires dependencies through a handful of annotations:

ConceptAnnotationWhat it means
Injectable@injectThe generator constructs this class via its constructor
Module@module/@providesProvides types you don’t own (third-party, interfaces)
Singleton@singletonOne shared instance for the component’s lifetime
Assisted@assistedInjectMixes graph-injected and runtime parameters
Component@ComponentRoot of the graph; exposes entry points

Everything starts from a @Component. The generator traces the graph from its entry points and resolves every dependency transitively — at build time.

The files

The app is split by layer, mirroring flutter_demo:

lib/
├── main.dart                                           @Component root (MainComponent)
└── src/
    ├── app_module.dart                                 AppModule (initial count, …)
    ├── domain/
    │   ├── models/counter.dart                         Counter (immutable domain model)
    │   └── use_cases/increment_counter_use_case.dart   IncrementCounterUseCase
    ├── data/
    │   ├── services/database.dart                      Database + DatabaseModule
    │   └── repositories/counter_repository.dart        CounterRepository
    └── features/
        ├── app/my_app.dart                             MyApp (@assistedInject)
        └── home/
            ├── home_page.dart                          HomePage (@assistedInject)
            └── counter_view_model.dart                 CounterViewModel

Step 1 — Provide a third-party type with a module

Database stands in for a third-party library (Drift, Hive, Isar, …). It can’t carry @inject, so a @module binds it. The module also shows the two-line @Qualifier form to distinguish two String config values of the same Dart type (more in Core Concepts):

// lib/src/data/services/database.dart
import 'package:inject_annotation/inject_annotation.dart';

const databasePath = Qualifier(#databasePath);
const databaseName = Qualifier(#databaseName);

@module
class DatabaseModule {
  @provides
  @databasePath
  String provideDatabasePath() => '/data/counter.db';

  @provides
  @databaseName
  String provideDatabaseName() => 'counter_db';

  @provides
  @singleton
  Database provideDatabase(
    @databasePath String path,
    @databaseName String name,
  ) => Database(path: path, name: name);
}

class Database {
  Database({required this.path, required this.name});

  final String path;
  final String name;
  int _count = 0;

  Future<void> updateCount(int count) async => _count = count;
  Future<int> selectCount() => Future.value(_count);
}

@provides tells the generator how to build a Database; @singleton shares one instance across the graph. Full source: database.dart.

Step 2 — Annotate your own classes with @inject

Counter is a plain immutable domain model — no annotation needed:

// lib/src/domain/models/counter.dart
class Counter {
  const Counter({this.value = 0});

  final int value;

  Counter copyWith({int? value}) => Counter(value: value ?? this.value);
}

The repository wraps the service and returns domain models; the use case encapsulates one operation. Both are stateless, so both are @singleton:

// lib/src/data/repositories/counter_repository.dart
@inject
@singleton
class CounterRepository {
  const CounterRepository({required this._database});

  final Database _database;

  Future<Counter> get counter async =>
      Counter(value: await _database.selectCount());

  Future<void> increment() async {
    final current = await _database.selectCount();
    await _database.updateCount(current + 1);
  }
}
// lib/src/domain/use_cases/increment_counter_use_case.dart
@inject
@singleton
class IncrementCounterUseCase {
  const IncrementCounterUseCase({required this._repository});

  final CounterRepository _repository;

  Future<Counter> execute() async {
    await _repository.increment();
    return _repository.counter;
  }
}

@inject tells the generator to construct the class via its constructor, resolving each parameter from the graph. Full source: counter_repository.dart, increment_counter_use_case.dart.

Step 3 — The ViewModel

The ViewModel holds the mutable UI state and extends ChangeNotifier. It is not a singleton — each screen gets its own:

// lib/src/features/home/counter_view_model.dart
@inject
class CounterViewModel extends ChangeNotifier {
  CounterViewModel({
    required this._incrementUseCase,
    required this._initialCount,
  });

  final IncrementCounterUseCase _incrementUseCase;
  final Future<int> _initialCount;

  Counter _counter = const Counter();
  Counter get counter => _counter;

  Future<void> init() async {
    _counter = Counter(value: await _initialCount);
    notifyListeners();
  }

  Future<void> increment() async {
    _counter = await _incrementUseCase.execute();
    notifyListeners();
  }
}

_initialCount is a raw Future<int> binding (no @asynchronous) — the ViewModel awaits it itself in init(). This keeps the ViewModel’s dependency chain synchronous, which ViewModelFactory requires (the two async patterns are explained in Core Concepts). Full source: counter_view_model.dart.

Step 4 — Widgets with @assistedInject

A widget needs DI-managed dependencies (a ViewModelFactory) and runtime parameters (key, title). @assistedInject mixes the two. Each file that declares an @assistedInject constructor needs its own part '<file>.factory.dart'; directive so the factory builder can write the generated factory next to it:

// lib/src/features/home/home_page.dart
import 'package:flutter/material.dart';
import 'package:inject_annotation/inject_annotation.dart';
import 'package:inject_flutter/inject_flutter.dart';

import 'counter_view_model.dart';

part 'home_page.factory.dart';

class HomePage extends StatelessWidget {
  @assistedInject
  const HomePage({
    @assisted super.key,
    @assisted required this.title,
    required this.viewModelFactory,
  });

  final String title;
  final ViewModelFactory<CounterViewModel> viewModelFactory;

  @override
  Widget build(BuildContext context) {
    return viewModelFactory(
      init: (vm) => vm.init(),
      loading: const Center(child: CircularProgressIndicator()),
      builder: (context, vm, _) => Scaffold(
        appBar: AppBar(title: Text(title)),
        body: Center(child: Text('${vm.counter.value}')),
        floatingActionButton: FloatingActionButton(
          onPressed: vm.increment,
          child: const Icon(Icons.add),
        ),
      ),
    );
  }
}
  • @assisted parameters (key, title) come from the caller at runtime.
  • Parameters without @assisted (viewModelFactory) come from the graph.
  • The generator synthesises a HomePageFactory into home_page.factory.dart — you never write it.
  • ViewModelFactory<CounterViewModel> (from inject_flutter) creates the VM in initState, runs the init: callback you pass once (awaiting it when async and showing loading: meanwhile), rebuilds the subtree on notifyListeners(), and disposes the VM in dispose. Passing init: (vm) => vm.init() is what runs init() — the factory does not call it automatically.

The root MyApp widget follows the same pattern — its own file, its own part directive, injecting the synthesised HomePageFactory:

// lib/src/features/app/my_app.dart
import 'package:flutter/material.dart';
import 'package:inject_annotation/inject_annotation.dart';

import '../home/home_page.dart';

part 'my_app.factory.dart';

class MyApp extends StatelessWidget {
  @assistedInject
  const MyApp({@assisted super.key, required this.homePageFactory});

  final HomePageFactory homePageFactory;

  @override
  Widget build(BuildContext context) => MaterialApp(
        home: homePageFactory.create(title: 'Flutter Counter Demo'),
      );
}

Full source: home_page.dart, my_app.dart.

Step 5 — A module for the initial count

CounterViewModel needs a Future<int> for its initial value. A small AppModule provides it:

// lib/src/app_module.dart
@module
class AppModule {
  @provides
  @singleton
  Future<int> provideInitialCount() => Future.value(0);
}

In flutter_demo, AppModule also wires app metadata (an @asynchronous AppInfo), a @welcome message, and a @provisionListener — see app_module.dart and Core Concepts.

Step 6 — Declare the component

The component is the graph root. It lists its modules and exposes entry points. A component is an abstract graph declaration — it never declares constructors of its own. Since no class in main.dart declares an @assistedInject constructor, the file needs no part directive; it imports the generated component library instead:

// lib/main.dart
import 'main.inject.dart' as g;

@Component([AppModule, DatabaseModule])
abstract class MainComponent {
  static const create = g.MainComponent$Component.create;

  @inject
  MyAppFactory get myAppFactory;
  // flutter_demo also exposes welcomeMessage, counterRepositoryProvider,
  // and creationLogListener — see main.dart.
}
  • import 'main.inject.dart' as g; — the generated component class lives here; the as g prefix keeps generated names out of your namespace.
  • Module order matters: a later module overrides an earlier one for the same (type, qualifier) key.

Step 7 — Run the generator

dart run build_runner build

The generator writes, next to each source file:

  • lib/main.inject.dart — the component implementation (next to main.dart).
  • lib/src/features/home/home_page.factory.dartHomePageFactory.
  • lib/src/features/app/my_app.factory.dartMyAppFactory.

A .factory.dart is emitted only for files that declare @assistedInject (or @assistedFactory). main.dart has neither, so there is no main.factory.dart.

Step 8 — Use the component in main()

void main() {
  final component = MainComponent.create();
  runApp(component.myAppFactory.create());
}

MainComponent.create() returns the wired component; myAppFactory.create() produces a MyApp with every injected dependency already in place.

Troubleshooting

“Component ‘MainComponent’ has no entry points”

A @Component must expose at least one entry point — an abstract getter or method (here, myAppFactory). Add one if your component has none.

“Could not find a way to provide X”

The generator has no binding for a dependency. Common causes:

  • the class is missing @inject;
  • it’s a third-party type with no @module + @provides;
  • the module is not listed in @Component([...]);
  • a @Qualifier on the consumer matches no provider.

Missing part '<file>.factory.dart'; directive

A file with an @assistedInject (or @assistedFactory) constructor needs a part '<file>.factory.dart'; directive in that same file — for example part 'home_page.factory.dart'; in home_page.dart. It is not added to the component file unless another class in that same file declares an assisted constructor (the component itself never does).

Complete example

The full, running source is in flutter_demo. For a single-file variant, see examples/example/lib/main.dart. The deeper architecture walkthrough is in State Management and Application Architecture.