App Architecture & Design Patterns

Code Generation with injectable & build_runner

16 min Lesson 9 of 12

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.

Why bother? Manual GetIt registration is error-prone — a missing call silently breaks the app at runtime instead of compile time. injectable turns wiring errors into compile-time errors and eliminates hundreds of lines of repetitive code as your project grows.

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);
}
Choose the right scope: Use @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.

Conflicting outputs: If you rename a class or move a file, the previous generated output can conflict with the new one. Always pass --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.

Key Takeaways: @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.