DartFlutter

Dependency Injection in Flutter with Injectable and GetIt

20250402 1005 Flutter Dependency Injection Simple Compose 01jqtrh8cjfyhrps8v4rmzgjvx 1024x683

Introduction

Manually registering dependencies in GetIt works fine for small apps, but it quickly becomes tedious and error-prone in larger projects. You end up with a massive configuration file where adding a new service means remembering to register it, its dependencies, and ensuring the registration order is correct. That’s where Injectable comes in—a code generation tool that integrates with GetIt to automate dependency registration through simple annotations. Companies building production Flutter apps use this combination to maintain clean, testable architectures that scale. In this comprehensive guide, you’ll learn how to use Injectable with GetIt to create scalable, boilerplate-free dependency injection, including environment-based configuration, module registration, and testing patterns.

Why Dependency Injection Matters

Dependency injection provides critical benefits for maintainable code:

Testability: Swap real implementations for mocks during testing. Test your ViewModel without hitting real APIs.

Loose coupling: Classes depend on abstractions, not concrete implementations. Change your API client without touching every screen.

Single source of truth: Configuration lives in one place. Need to change how Dio is configured? Update one registration.

Lifecycle management: Control whether dependencies are singletons, lazy singletons, or factories.

Setting Up Injectable with GetIt

Step 1: Add Dependencies

# pubspec.yaml
dependencies:
  get_it: ^7.6.7
  injectable: ^2.4.1

dev_dependencies:
  build_runner: ^2.4.8
  injectable_generator: ^2.4.1
flutter pub get

Step 2: Create the Service Locator

// lib/core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit(
  initializerName: 'init',
  preferRelativeImports: true,
  asExtension: true,
)
Future configureDependencies({String? environment}) async {
  getIt.init(environment: environment);
}

Step 3: Generate the Configuration

flutter pub run build_runner build --delete-conflicting-outputs

This generates injection.config.dart containing all your registrations.

Registration Types and When to Use Them

Injectable provides several annotations for different use cases:

// @injectable - Creates a new instance every time
// Use for: Stateless services, use cases, mappers
@injectable
class FetchUserUseCase {
  final UserRepository repository;

  FetchUserUseCase(this.repository);

  Future call(String userId) => repository.getUser(userId);
}

// @singleton - Created once, same instance always
// Use for: Services with state that must persist
@singleton
class AuthService {
  User? _currentUser;
  
  User? get currentUser => _currentUser;
  
  void setUser(User user) => _currentUser = user;
  void clearUser() => _currentUser = null;
}

// @lazySingleton - Created on first access, same instance after
// Use for: Heavy services you might not always need
@lazySingleton
class AnalyticsService {
  AnalyticsService() {
    // Expensive initialization
    _initialize();
  }
  
  void _initialize() {
    // Setup analytics SDK
  }
  
  void trackEvent(String name, Map params) {
    // Track event
  }
}

Registering Abstract Classes with Implementations

For clean architecture, register implementations against abstract interfaces:

// lib/features/auth/domain/repositories/auth_repository.dart
abstract class AuthRepository {
  Future login(String email, String password);
  Future logout();
  Future getCurrentUser();
}

// lib/features/auth/data/repositories/auth_repository_impl.dart
@LazySingleton(as: AuthRepository)
class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  final AuthLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  AuthRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
    required this.networkInfo,
  });

  @override
  Future login(String email, String password) async {
    if (await networkInfo.isConnected) {
      final user = await remoteDataSource.login(email, password);
      await localDataSource.cacheUser(user);
      return user;
    } else {
      throw NetworkException('No internet connection');
    }
  }

  @override
  Future logout() async {
    await remoteDataSource.logout();
    await localDataSource.clearCache();
  }

  @override
  Future getCurrentUser() async {
    return localDataSource.getCachedUser();
  }
}

Modules for Third-Party Dependencies

External libraries can’t be annotated directly. Use modules:

// lib/core/di/modules/network_module.dart
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';

@module
abstract class NetworkModule {
  @lazySingleton
  Dio get dio => Dio(BaseOptions(
    baseUrl: 'https://api.example.com',
    connectTimeout: const Duration(seconds: 30),
    receiveTimeout: const Duration(seconds: 30),
    headers: {'Content-Type': 'application/json'},
  ))..interceptors.addAll([
    LogInterceptor(requestBody: true, responseBody: true),
    AuthInterceptor(getIt()),
  ]);

  @preResolve
  Future get prefs => SharedPreferences.getInstance();
}

// lib/core/di/modules/database_module.dart
@module
abstract class DatabaseModule {
  @preResolve
  Future get database async {
    return await $FloorAppDatabase
        .databaseBuilder('app_database.db')
        .build();
  }
}

Note: @preResolve requires async initialization in main:

// lib/main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await configureDependencies();
  runApp(const MyApp());
}

Environment-Based Configuration

Register different implementations for different environments:

// lib/core/di/environments.dart
const dev = Environment('dev');
const staging = Environment('staging');
const prod = Environment('prod');

// lib/features/analytics/data/analytics_service_impl.dart
@LazySingleton(as: AnalyticsService)
@prod
@staging
class RealAnalyticsService implements AnalyticsService {
  @override
  void trackEvent(String name, Map params) {
    // Send to real analytics
  }
}

@LazySingleton(as: AnalyticsService)
@dev
class FakeAnalyticsService implements AnalyticsService {
  @override
  void trackEvent(String name, Map params) {
    print('Analytics: $name - $params');
  }
}

// Initialize with environment
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  const environment = String.fromEnvironment('ENV', defaultValue: 'dev');
  await configureDependencies(environment: environment);
  
  runApp(const MyApp());
}

Testing with Injectable

Override registrations for testing:

// test/helpers/test_injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'test_injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit(generateForDir: ['test'])
void configureDependencies() => getIt.init();

// test/mocks/mock_auth_repository.dart
@LazySingleton(as: AuthRepository)
@test
class MockAuthRepository implements AuthRepository {
  User? mockUser;
  bool shouldFail = false;

  @override
  Future login(String email, String password) async {
    if (shouldFail) throw AuthException('Invalid credentials');
    return mockUser ?? User(id: '1', email: email, name: 'Test User');
  }

  @override
  Future logout() async {}

  @override
  Future getCurrentUser() async => mockUser;
}

// test/features/auth/login_usecase_test.dart
void main() {
  late LoginUseCase useCase;
  late MockAuthRepository mockRepository;

  setUp(() {
    getIt.reset();
    configureDependencies();
    mockRepository = getIt() as MockAuthRepository;
    useCase = getIt();
  });

  test('successful login returns user', () async {
    mockRepository.mockUser = User(id: '1', email: 'test@test.com', name: 'Test');

    final result = await useCase(LoginParams(
      email: 'test@test.com',
      password: 'password',
    ));

    expect(result.isRight(), true);
  });

  test('failed login returns failure', () async {
    mockRepository.shouldFail = true;

    final result = await useCase(LoginParams(
      email: 'test@test.com',
      password: 'wrong',
    ));

    expect(result.isLeft(), true);
  });
}

Complete Feature Registration Example

// Complete auth feature with all layers

// Data Sources
@LazySingleton()
class AuthRemoteDataSource {
  final Dio dio;
  
  AuthRemoteDataSource(this.dio);
  
  Future login(String email, String password) async {
    final response = await dio.post('/auth/login', data: {
      'email': email,
      'password': password,
    });
    return UserModel.fromJson(response.data['user']);
  }
}

@LazySingleton()
class AuthLocalDataSource {
  final SharedPreferences prefs;
  
  AuthLocalDataSource(this.prefs);
  
  Future cacheUser(UserModel user) async {
    await prefs.setString('cached_user', jsonEncode(user.toJson()));
  }
  
  UserModel? getCachedUser() {
    final json = prefs.getString('cached_user');
    if (json == null) return null;
    return UserModel.fromJson(jsonDecode(json));
  }
}

// Use Cases
@injectable
class LoginUseCase {
  final AuthRepository repository;
  
  LoginUseCase(this.repository);
  
  Future> call(LoginParams params) async {
    try {
      final user = await repository.login(params.email, params.password);
      return Right(user);
    } on AuthException catch (e) {
      return Left(AuthFailure(e.message));
    }
  }
}

// Accessing dependencies
class LoginScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final loginUseCase = getIt();
    // Use the use case...
  }
}

Common Mistakes to Avoid

Forgetting to run build_runner: After adding new annotations, always regenerate: flutter pub run build_runner build

Circular dependencies: If A depends on B and B depends on A, injection fails. Restructure to break the cycle.

Accessing getIt before initialization: Always call configureDependencies() before runApp().

Using getIt directly in widgets: Consider using a provider pattern or passing dependencies through constructors for better testability.

Over-injecting: Not everything needs DI. Simple utility functions and pure data classes don’t need registration.

Conclusion

Combining Injectable with GetIt gives you powerful dependency injection without manual boilerplate. Annotate your classes, run build_runner, and your DI is configured automatically. Use abstract types for testability, modules for third-party libraries, and environments for different configurations. The result is a codebase that’s easier to test, maintain, and scale. Start using Injectable in your next Flutter project—the time saved on manual registration adds up quickly. For more on Flutter architecture, check out our guide on Scalable Flutter Project Structure. For advanced DI patterns, explore the Injectable package documentation.

Leave a Comment