DartFlutter

Dependency Injection in Flutter with GetIt

20250402 0931 Flutter Dependency Management Simple Compose 01jqtpkgyhe3h8ghzw8kbf6t4p 1024x683

As your Flutter app grows, managing dependencies manually can get messy and hard to scale. That’s where dependency injection (DI) comes in—and in Flutter, GetIt is one of the most popular and powerful packages for implementing it.

In this comprehensive guide, you’ll learn what dependency injection is, why it matters, and how to use GetIt in your Flutter projects to make your code more modular, testable, and maintainable. Flutter dependency injection ensures your app remains structured and clean as it scales to hundreds of screens and services.

What is Dependency Injection?

Dependency Injection is a design pattern where you provide an object’s dependencies from the outside rather than creating them inside the object. This follows the Inversion of Control (IoC) principle, making your code more flexible and easier to maintain.

Consider the difference between tightly coupled and loosely coupled code:

// Tightly coupled - hard to test and maintain
class UserRepository {
  final ApiClient _apiClient = ApiClient(); // Creates its own dependency
  final DatabaseHelper _db = DatabaseHelper();
  
  Future<User> getUser(String id) async {
    try {
      return await _apiClient.fetchUser(id);
    } catch (e) {
      return await _db.getCachedUser(id);
    }
  }
}

// Loosely coupled with DI - easy to test and maintain
class UserRepository {
  final ApiClient _apiClient;
  final DatabaseHelper _db;
  
  UserRepository(this._apiClient, this._db); // Dependencies injected
  
  Future<User> getUser(String id) async {
    try {
      return await _apiClient.fetchUser(id);
    } catch (e) {
      return await _db.getCachedUser(id);
    }
  }
}

With dependency injection, you can easily swap implementations for testing or different environments. This is particularly powerful in Flutter when adopting dependency injection patterns.

Why Use GetIt for Flutter?

GetIt is a simple service locator for Dart and Flutter that allows you to register classes and retrieve them from anywhere in your app without tight coupling. Unlike other DI solutions, GetIt doesn’t require any code generation or complex setup.

Key Benefits of GetIt:

  • Global access to registered services without passing through widget trees
  • Cleaner code and better separation of concerns
  • No build_runner required unlike injectable or other code-gen solutions
  • Multiple registration types: singletons, lazy singletons, and factories
  • Scopes and named instances for complex scenarios
  • Async registration for services that need initialization
  • Easy testing with mock implementations

Setting Up GetIt in Your Flutter Project

Step 1: Add Dependencies

Add GetIt to your pubspec.yaml:

dependencies:
  get_it: ^7.6.7
  injectable: ^2.3.2  # Optional: for code generation

dev_dependencies:
  injectable_generator: ^2.4.1  # Optional: for code generation
  build_runner: ^2.4.8

Then run:

flutter pub get

Step 2: Create the Service Locator

Create a dedicated file lib/core/di/service_locator.dart to centralize all registrations:

import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;

// Services
import '../services/api_client.dart';
import '../services/auth_service.dart';
import '../services/storage_service.dart';
import '../services/analytics_service.dart';

// Repositories
import '../../features/auth/data/repositories/auth_repository_impl.dart';
import '../../features/auth/domain/repositories/auth_repository.dart';
import '../../features/users/data/repositories/user_repository_impl.dart';
import '../../features/users/domain/repositories/user_repository.dart';

// Use cases
import '../../features/auth/domain/usecases/login_usecase.dart';
import '../../features/auth/domain/usecases/logout_usecase.dart';
import '../../features/users/domain/usecases/get_user_profile_usecase.dart';

// BLoCs/Cubits
import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/users/presentation/cubit/user_profile_cubit.dart';

final GetIt sl = GetIt.instance;

Future<void> setupServiceLocator() async {
  // External dependencies
  sl.registerLazySingleton<http.Client>(() => http.Client());
  
  // Core services
  await _registerCoreServices();
  
  // Feature: Auth
  _registerAuthFeature();
  
  // Feature: Users
  _registerUsersFeature();
}

Future<void> _registerCoreServices() async {
  // Storage needs async initialization
  final storageService = StorageService();
  await storageService.init();
  sl.registerSingleton<StorageService>(storageService);
  
  // API Client depends on storage for auth tokens
  sl.registerLazySingleton<ApiClient>(
    () => ApiClient(
      client: sl<http.Client>(),
      storage: sl<StorageService>(),
    ),
  );
  
  sl.registerLazySingleton<AnalyticsService>(() => AnalyticsService());
}

void _registerAuthFeature() {
  // Repository
  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(
      apiClient: sl<ApiClient>(),
      storage: sl<StorageService>(),
    ),
  );
  
  // Use cases
  sl.registerLazySingleton(() => LoginUseCase(sl<AuthRepository>()));
  sl.registerLazySingleton(() => LogoutUseCase(sl<AuthRepository>()));
  
  // BLoC - Factory because we want a new instance per screen
  sl.registerFactory<AuthBloc>(
    () => AuthBloc(
      loginUseCase: sl<LoginUseCase>(),
      logoutUseCase: sl<LogoutUseCase>(),
      analytics: sl<AnalyticsService>(),
    ),
  );
}

void _registerUsersFeature() {
  // Repository
  sl.registerLazySingleton<UserRepository>(
    () => UserRepositoryImpl(apiClient: sl<ApiClient>()),
  );
  
  // Use cases
  sl.registerLazySingleton(() => GetUserProfileUseCase(sl<UserRepository>()));
  
  // Cubit
  sl.registerFactory<UserProfileCubit>(
    () => UserProfileCubit(getUserProfile: sl<GetUserProfileUseCase>()),
  );
}

Step 3: Initialize in main.dart

import 'package:flutter/material.dart';
import 'core/di/service_locator.dart';
import 'app.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Setup dependency injection
  await setupServiceLocator();
  
  runApp(const MyApp());
}

Understanding GetIt Registration Types

GetIt offers several registration methods, each suited for different use cases:

1. Singleton (Eager)

Creates the instance immediately when registered:

// Instance created immediately
sl.registerSingleton<DatabaseService>(DatabaseService());

// Useful when you need initialization before app starts
final dbService = DatabaseService();
await dbService.initialize();
sl.registerSingleton<DatabaseService>(dbService);

2. LazySingleton

Creates the instance only when first accessed:

// Instance created on first sl<ApiClient>() call
sl.registerLazySingleton<ApiClient>(() => ApiClient());

// Perfect for services that might not be used in every session
sl.registerLazySingleton<PaymentService>(() => StripePaymentService());

3. Factory

Creates a new instance every time it’s requested:

// New instance every time
sl.registerFactory<LoginViewModel>(() => LoginViewModel());

// Perfect for ViewModels, BLoCs, Cubits that should be fresh per screen
sl.registerFactory<ProductDetailsCubit>(
  () => ProductDetailsCubit(repository: sl<ProductRepository>()),
);

4. Factory with Parameters

Creates instances with runtime parameters:

// Register with parameters
sl.registerFactoryParam<ProductDetailsCubit, String, void>(
  (productId, _) => ProductDetailsCubit(
    productId: productId,
    repository: sl<ProductRepository>(),
  ),
);

// Usage
final cubit = sl<ProductDetailsCubit>(param1: 'product-123');

5. Async Registration

For services that need asynchronous initialization:

// Register async singleton
sl.registerSingletonAsync<SharedPreferences>(
  () async => SharedPreferences.getInstance(),
);

sl.registerSingletonAsync<DatabaseService>(
  () async {
    final db = DatabaseService();
    await db.initialize();
    return db;
  },
);

// Wait for all async registrations
await sl.allReady();

Complete Real-World Example

Let’s build a complete example with a clean architecture structure:

API Client Service

// lib/core/services/api_client.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'storage_service.dart';
import '../exceptions/api_exception.dart';

class ApiClient {
  final http.Client _client;
  final StorageService _storage;
  final String _baseUrl;
  
  ApiClient({
    required http.Client client,
    required StorageService storage,
    String baseUrl = 'https://api.example.com/v1',
  }) : _client = client,
       _storage = storage,
       _baseUrl = baseUrl;
  
  Future<Map<String, String>> get _headers async {
    final token = await _storage.getToken();
    return {
      'Content-Type': 'application/json',
      if (token != null) 'Authorization': 'Bearer $token',
    };
  }
  
  Future<T> get<T>(
    String endpoint, {
    T Function(Map<String, dynamic>)? fromJson,
  }) async {
    final response = await _client.get(
      Uri.parse('$_baseUrl$endpoint'),
      headers: await _headers,
    );
    
    return _handleResponse(response, fromJson);
  }
  
  Future<T> post<T>(
    String endpoint, {
    Map<String, dynamic>? body,
    T Function(Map<String, dynamic>)? fromJson,
  }) async {
    final response = await _client.post(
      Uri.parse('$_baseUrl$endpoint'),
      headers: await _headers,
      body: body != null ? jsonEncode(body) : null,
    );
    
    return _handleResponse(response, fromJson);
  }
  
  T _handleResponse<T>(
    http.Response response,
    T Function(Map<String, dynamic>)? fromJson,
  ) {
    if (response.statusCode >= 200 && response.statusCode < 300) {
      final data = jsonDecode(response.body) as Map<String, dynamic>;
      if (fromJson != null) {
        return fromJson(data);
      }
      return data as T;
    }
    
    throw ApiException(
      statusCode: response.statusCode,
      message: _parseErrorMessage(response.body),
    );
  }
  
  String _parseErrorMessage(String body) {
    try {
      final data = jsonDecode(body);
      return data['message'] ?? 'Unknown error';
    } catch (_) {
      return 'Unknown error';
    }
  }
}

Auth Repository with Interface

// lib/features/auth/domain/repositories/auth_repository.dart
import '../entities/user.dart';

abstract class AuthRepository {
  Future<User> login(String email, String password);
  Future<void> logout();
  Future<User?> getCurrentUser();
  Future<bool> isAuthenticated();
}

// lib/features/auth/data/repositories/auth_repository_impl.dart
import '../../domain/repositories/auth_repository.dart';
import '../../domain/entities/user.dart';
import '../../../../core/services/api_client.dart';
import '../../../../core/services/storage_service.dart';
import '../models/user_model.dart';
import '../models/login_response_model.dart';

class AuthRepositoryImpl implements AuthRepository {
  final ApiClient _apiClient;
  final StorageService _storage;
  
  AuthRepositoryImpl({
    required ApiClient apiClient,
    required StorageService storage,
  }) : _apiClient = apiClient,
       _storage = storage;
  
  @override
  Future<User> login(String email, String password) async {
    final response = await _apiClient.post<LoginResponseModel>(
      '/auth/login',
      body: {'email': email, 'password': password},
      fromJson: LoginResponseModel.fromJson,
    );
    
    await _storage.saveToken(response.accessToken);
    await _storage.saveRefreshToken(response.refreshToken);
    await _storage.saveUser(response.user);
    
    return response.user;
  }
  
  @override
  Future<void> logout() async {
    try {
      await _apiClient.post('/auth/logout');
    } finally {
      await _storage.clearAll();
    }
  }
  
  @override
  Future<User?> getCurrentUser() async {
    return _storage.getUser();
  }
  
  @override
  Future<bool> isAuthenticated() async {
    final token = await _storage.getToken();
    return token != null;
  }
}

Use Case Layer

// lib/features/auth/domain/usecases/login_usecase.dart
import '../entities/user.dart';
import '../repositories/auth_repository.dart';

class LoginParams {
  final String email;
  final String password;
  
  LoginParams({required this.email, required this.password});
}

class LoginUseCase {
  final AuthRepository _repository;
  
  LoginUseCase(this._repository);
  
  Future<User> call(LoginParams params) async {
    // Add business logic validation
    if (!_isValidEmail(params.email)) {
      throw ValidationException('Invalid email format');
    }
    
    if (params.password.length < 8) {
      throw ValidationException('Password must be at least 8 characters');
    }
    
    return _repository.login(params.email, params.password);
  }
  
  bool _isValidEmail(String email) {
    return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email);
  }
}

BLoC with Injected Dependencies

// lib/features/auth/presentation/bloc/auth_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/usecases/login_usecase.dart';
import '../../domain/usecases/logout_usecase.dart';
import '../../domain/entities/user.dart';
import '../../../../core/services/analytics_service.dart';

part 'auth_bloc.freezed.dart';

@freezed
class AuthEvent with _$AuthEvent {
  const factory AuthEvent.login({required String email, required String password}) = _Login;
  const factory AuthEvent.logout() = _Logout;
  const factory AuthEvent.checkAuth() = _CheckAuth;
}

@freezed
class AuthState with _$AuthState {
  const factory AuthState.initial() = _Initial;
  const factory AuthState.loading() = _Loading;
  const factory AuthState.authenticated(User user) = _Authenticated;
  const factory AuthState.unauthenticated() = _Unauthenticated;
  const factory AuthState.error(String message) = _Error;
}

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase _loginUseCase;
  final LogoutUseCase _logoutUseCase;
  final AnalyticsService _analytics;
  
  AuthBloc({
    required LoginUseCase loginUseCase,
    required LogoutUseCase logoutUseCase,
    required AnalyticsService analytics,
  }) : _loginUseCase = loginUseCase,
       _logoutUseCase = logoutUseCase,
       _analytics = analytics,
       super(const AuthState.initial()) {
    on<_Login>(_onLogin);
    on<_Logout>(_onLogout);
  }
  
  Future<void> _onLogin(_Login event, Emitter<AuthState> emit) async {
    emit(const AuthState.loading());
    
    try {
      final user = await _loginUseCase(
        LoginParams(email: event.email, password: event.password),
      );
      
      _analytics.logEvent('login_success', {'user_id': user.id});
      emit(AuthState.authenticated(user));
    } on ValidationException catch (e) {
      emit(AuthState.error(e.message));
    } on ApiException catch (e) {
      _analytics.logEvent('login_failed', {'error': e.message});
      emit(AuthState.error(e.message));
    }
  }
  
  Future<void> _onLogout(_Logout event, Emitter<AuthState> emit) async {
    await _logoutUseCase();
    emit(const AuthState.unauthenticated());
  }
}

Using in Widgets

// lib/features/auth/presentation/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/service_locator.dart';
import '../bloc/auth_bloc.dart';

class LoginPage extends StatelessWidget {
  const LoginPage({super.key});
  
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      // GetIt provides a fresh AuthBloc instance
      create: (_) => sl<AuthBloc>(),
      child: const LoginView(),
    );
  }
}

class LoginView extends StatefulWidget {
  const LoginView({super.key});
  
  @override
  State<LoginView> createState() => _LoginViewState();
}

class _LoginViewState extends State<LoginView> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _formKey = GlobalKey<FormState>();
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: BlocConsumer<AuthBloc, AuthState>(
        listener: (context, state) {
          state.maybeWhen(
            authenticated: (user) {
              Navigator.of(context).pushReplacementNamed('/home');
            },
            error: (message) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(message)),
              );
            },
            orElse: () {},
          );
        },
        builder: (context, state) {
          final isLoading = state.maybeWhen(
            loading: () => true,
            orElse: () => false,
          );
          
          return Padding(
            padding: const EdgeInsets.all(16),
            child: Form(
              key: _formKey,
              child: Column(
                children: [
                  TextFormField(
                    controller: _emailController,
                    decoration: const InputDecoration(labelText: 'Email'),
                    keyboardType: TextInputType.emailAddress,
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: _passwordController,
                    decoration: const InputDecoration(labelText: 'Password'),
                    obscureText: true,
                  ),
                  const SizedBox(height: 24),
                  SizedBox(
                    width: double.infinity,
                    child: ElevatedButton(
                      onPressed: isLoading ? null : _onLogin,
                      child: isLoading
                          ? const CircularProgressIndicator()
                          : const Text('Login'),
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
  
  void _onLogin() {
    context.read<AuthBloc>().add(
      AuthEvent.login(
        email: _emailController.text,
        password: _passwordController.text,
      ),
    );
  }
}

Testing with GetIt and Mock Implementations

One of the biggest advantages of dependency injection is testability. Here’s how to test with GetIt:

// test/features/auth/presentation/bloc/auth_bloc_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:get_it/get_it.dart';

// Mocks
class MockLoginUseCase extends Mock implements LoginUseCase {}
class MockLogoutUseCase extends Mock implements LogoutUseCase {}
class MockAnalyticsService extends Mock implements AnalyticsService {}

void main() {
  late AuthBloc authBloc;
  late MockLoginUseCase mockLoginUseCase;
  late MockLogoutUseCase mockLogoutUseCase;
  late MockAnalyticsService mockAnalytics;
  
  setUp(() {
    mockLoginUseCase = MockLoginUseCase();
    mockLogoutUseCase = MockLogoutUseCase();
    mockAnalytics = MockAnalyticsService();
    
    authBloc = AuthBloc(
      loginUseCase: mockLoginUseCase,
      logoutUseCase: mockLogoutUseCase,
      analytics: mockAnalytics,
    );
  });
  
  tearDown(() {
    authBloc.close();
  });
  
  group('AuthBloc', () {
    final testUser = User(id: '1', email: 'test@example.com', name: 'Test');
    
    blocTest<AuthBloc, AuthState>(
      'emits [loading, authenticated] when login succeeds',
      build: () {
        when(() => mockLoginUseCase(any())).thenAnswer(
          (_) async => testUser,
        );
        when(() => mockAnalytics.logEvent(any(), any())).thenAnswer(
          (_) async {},
        );
        return authBloc;
      },
      act: (bloc) => bloc.add(
        const AuthEvent.login(
          email: 'test@example.com',
          password: 'password123',
        ),
      ),
      expect: () => [
        const AuthState.loading(),
        AuthState.authenticated(testUser),
      ],
      verify: (_) {
        verify(() => mockAnalytics.logEvent('login_success', any())).called(1);
      },
    );
    
    blocTest<AuthBloc, AuthState>(
      'emits [loading, error] when login fails',
      build: () {
        when(() => mockLoginUseCase(any())).thenThrow(
          ApiException(statusCode: 401, message: 'Invalid credentials'),
        );
        when(() => mockAnalytics.logEvent(any(), any())).thenAnswer(
          (_) async {},
        );
        return authBloc;
      },
      act: (bloc) => bloc.add(
        const AuthEvent.login(
          email: 'test@example.com',
          password: 'wrong',
        ),
      ),
      expect: () => [
        const AuthState.loading(),
        const AuthState.error('Invalid credentials'),
      ],
    );
  });
}

// Test helper to reset GetIt between tests
void setupTestServiceLocator() {
  final sl = GetIt.instance;
  
  // Reset all registrations
  sl.reset();
  
  // Register mocks
  sl.registerSingleton<AuthRepository>(MockAuthRepository());
  sl.registerSingleton<AnalyticsService>(MockAnalyticsService());
}

Advanced GetIt Patterns

Environment-Based Registration

enum Environment { dev, staging, prod }

Future<void> setupServiceLocator(Environment env) async {
  // Different API URLs per environment
  final baseUrl = switch (env) {
    Environment.dev => 'https://dev-api.example.com',
    Environment.staging => 'https://staging-api.example.com',
    Environment.prod => 'https://api.example.com',
  };
  
  sl.registerSingleton(AppConfig(baseUrl: baseUrl, env: env));
  
  // Use mock services in dev
  if (env == Environment.dev) {
    sl.registerLazySingleton<PaymentService>(() => MockPaymentService());
  } else {
    sl.registerLazySingleton<PaymentService>(() => StripePaymentService());
  }
}

Named Instances

// Register multiple implementations of the same type
sl.registerLazySingleton<http.Client>(
  () => RetryClient(http.Client()),
  instanceName: 'retryClient',
);

sl.registerLazySingleton<http.Client>(
  () => CachingClient(http.Client()),
  instanceName: 'cachingClient',
);

// Retrieve specific instance
final retryClient = sl<http.Client>(instanceName: 'retryClient');

Scoped Dependencies

// Create a scope for a specific flow (e.g., checkout)
void startCheckoutFlow() {
  sl.pushNewScope(
    scopeName: 'checkout',
    init: (scope) {
      scope.registerSingleton<Cart>(Cart());
      scope.registerSingleton<CheckoutSession>(CheckoutSession());
    },
  );
}

void endCheckoutFlow() {
  sl.popScope();
}

Common Mistakes to Avoid

  1. Registering after app starts: Always complete registration before runApp()
  2. Using factory for shared state: Use singleton when state should persist
  3. Circular dependencies: Service A depends on B which depends on A – restructure your code
  4. Not resetting in tests: Call sl.reset() in setUp() or tearDown()
  5. Tight coupling to GetIt: Use interfaces so you can swap DI frameworks if needed
  6. Overusing service locator: Prefer constructor injection when possible, use GetIt at composition root

When to Use GetIt

Scenario Recommendation
Small apps (1-5 screens) Optional – constructor injection may suffice
Medium apps (5-20 screens) Recommended for service management
Large apps (20+ screens) Essential for maintainability
Apps with multiple data sources Highly recommended
Apps requiring extensive testing Essential for mock injection
Team projects Provides consistency and standards

Conclusion

Using GetIt for dependency injection in Flutter helps you write cleaner, scalable, and more maintainable code. The service locator pattern provides global access to dependencies without tight coupling, making your app easier to test and modify.

Key takeaways:

  • Use singletons for shared services that maintain state
  • Use factories for BLoCs, Cubits, and ViewModels that should be fresh per screen
  • Combine with interfaces for maximum testability
  • Register everything at app startup in a centralized location
  • Reset registrations between tests to ensure isolation

Whether you’re working on a small app or a large project, GetIt simplifies how you manage services and dependencies. For more state management patterns, check out our guide on Flutter state management packages and MVVM architecture in Flutter.

For additional reading, see the official GetIt package documentation and the Clean Architecture principles by Robert Martin.

Leave a Comment