Code Generation with injectable & build_runner
Code Generation with injectable & build_runner
In the previous lesson you wired every dependency into GetIt by hand inside a configureDependencies() function. That approach works, but it scales poorly: every new service, repository, or controller you add requires a manual registration call. injectable solves this by letting you annotate your Dart classes and then delegating all registration logic to the code generator. build_runner is the underlying Dart build tool that reads those annotations and writes the boilerplate for you.
Adding the Dependencies
You need three packages. Add them to pubspec.yaml:
pubspec.yaml
dependencies:
get_it: ^7.7.0
injectable: ^2.4.4 # runtime annotations
dev_dependencies:
injectable_generator: ^2.6.2 # the code generator
build_runner: ^2.4.9 # the build orchestrator
Run flutter pub get after saving. injectable goes in dependencies because the annotations live in your compiled code. injectable_generator and build_runner go in dev_dependencies because they are only needed during development, not in your production APK or IPA.
Setting Up the Injection Module
Create a file — typically lib/core/di/injection.dart — that declares the root of your injection container. The @InjectableInit annotation tells injectable where to generate the wiring code.
lib/core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart'; // generated — does not exist yet
final GetIt getIt = GetIt.instance;
@InjectableInit(
initializerName: 'init', // name of the generated function
preferRelativeImports: true, // use relative imports in generated file
asExtension: false, // generate a top-level function, not an extension
)
Future<void> configureDependencies() => init(getIt);
The file injection.config.dart does not exist yet — build_runner creates it. Your IDE will show a red error until you run the generator for the first time; that is expected.
Annotating Your Classes
injectable provides four core annotations. Apply exactly one to each class you want registered:
@injectable— a new instance is created each time the type is resolved (factory registration)@singleton— one instance is created eagerly when the container is built@lazySingleton— one instance is created the first time the type is resolved, then cached@module— marks an abstract class whose getters provide third-party or interface registrations
Annotating services and repositories
import 'package:injectable/injectable.dart';
// Registered as a factory — a new instance each call
@injectable
class AuthBloc {
final AuthRepository _repo;
AuthBloc(this._repo); // injectable reads constructor params automatically
}
// Registered as a lazy singleton — created once on first use
@lazySingleton
class AuthRepository {
final ApiClient _client;
AuthRepository(this._client);
Future<User> login(String email, String password) async {
return _client.post('/auth/login', {'email': email, 'password': password});
}
}
// Registered as an eager singleton — created immediately at startup
@singleton
class ApiClient {
final String baseUrl;
ApiClient(@Named('baseUrl') this.baseUrl);
}
@lazySingleton for expensive objects (HTTP clients, database connections) that should only be created when first needed. Use @singleton when the object must be ready before anything else runs. Use @injectable (factory) for BLoCs and ViewModels that should have fresh state per screen.Registering Third-Party Classes with @module
You cannot annotate classes you do not own (like Dio, SharedPreferences, or FlutterSecureStorage). Use a @module class instead:
lib/core/di/app_module.dart
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
@module
abstract class AppModule {
// Named value — injectable injects this wherever @Named('baseUrl') is used
@Named('baseUrl')
String get baseUrl => 'https://api.example.com';
// Async factory — build_runner generates the await automatically
@preResolve
Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
// Regular singleton from a third-party package
@lazySingleton
Dio get dio => Dio(BaseOptions(baseUrl: baseUrl));
}
The @preResolve annotation tells injectable that this getter returns a Future and must be awaited before the container is ready. This is why configureDependencies() returns Future<void>.
Running the Generator
Once your annotations are in place, run build_runner from your project root:
Terminal
# One-shot generation (CI, pre-commit)
dart run build_runner build --delete-conflicting-outputs
# Watch mode during active development (re-runs on every save)
dart run build_runner watch --delete-conflicting-outputs
build_runner scans every Dart file for injectable annotations, resolves the dependency graph, and writes lib/core/di/injection.config.dart. Commit this generated file to version control so your teammates and CI do not need to regenerate it from scratch on every checkout.
--delete-conflicting-outputs to let build_runner clean up stale artefacts automatically.The Generated File
You will rarely need to read the generated file, but understanding its shape builds confidence. A simplified excerpt looks like this:
injection.config.dart (auto-generated — do not edit by hand)
// GENERATED CODE - DO NOT MODIFY BY HAND
Future<void> init(GetIt getIt, {String? environment}) async {
// @preResolve — awaited before anything else
final sharedPreferences = await SharedPreferences.getInstance();
getIt.registerSingleton<SharedPreferences>(sharedPreferences);
// @singleton
getIt.registerSingleton<ApiClient>(
ApiClient(getIt<String>(instanceName: 'baseUrl')),
);
// @lazySingleton
getIt.registerLazySingleton<AuthRepository>(
() => AuthRepository(getIt<ApiClient>()),
);
// @injectable (factory)
getIt.registerFactory<AuthBloc>(
() => AuthBloc(getIt<AuthRepository>()),
);
}
Environments: dev, test, prod
injectable supports named environments. Annotate alternate implementations with @Environment('test') (or the built-in @dev, @prod, @test constants) and pass the active environment when initialising:
Environment-specific registrations
// Production implementation
@prod
@LazySingleton(as: AuthRepository)
class RemoteAuthRepository implements AuthRepository { ... }
// Test / mock implementation
@test
@LazySingleton(as: AuthRepository)
class FakeAuthRepository implements AuthRepository { ... }
// main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies(environment: Environment.prod);
runApp(const MyApp());
}
Summary
injectable and build_runner work as a team: you annotate, the generator wires. The workflow is: add annotations → run dart run build_runner build → commit the generated file → use getIt<T>() as before. The result is a fully type-safe, compile-error-checked injection container with zero hand-written boilerplate. Pair it with environment support and @module for third-party types, and your dependency graph becomes maintainable at any scale.
@injectable = factory, @singleton = eager singleton, @lazySingleton = lazy singleton, @module = register types you do not own. Run build_runner build after every annotation change. Commit the generated injection.config.dart file.