Dependency Injection
Overview
Dependency Injection (DI) is a design pattern that implements Inversion of Control (IoC), allowing dependencies to be provided to classes rather than having classes create them internally. This improves testability, maintainability, and flexibility.
Benefits
- Testability: Easy to mock dependencies in unit tests
- Flexibility: Swap implementations without changing dependent code
- Maintainability: Clear dependency graph and separation of concerns
- Reusability: Components can be reused with different dependencies
Core Principle: Constructor Injection
Always prefer constructor injection over property injection. Service locators are acceptable only at the composition root (the app wiring layer), not inside domain/data/business logic.
// GOOD: Constructor injection
class LoginViewModel {
private let loginUseCase: LoginUseCase
init(loginUseCase: LoginUseCase) {
self.loginUseCase = loginUseCase
}
}
// BAD: Service locator
class LoginViewModel {
private let loginUseCase = ServiceLocator.shared.loginUseCase // Hard to test
}
iOS (Swift) - Manual DI Container
For iOS projects, a simple manual DI container is recommended for production use.
Basic Container
// App/Core/DI/AppContainer.swift
final class AppContainer {
// MARK: - Infrastructure
lazy var apiClient: APIClient = {
URLSessionAPIClient(
baseURL: Configuration.apiBaseURL,
tokenStore: tokenStore
)
}()
lazy var tokenStore: TokenStore = {
KeychainTokenStore()
}()
lazy var database: Database = {
SwiftDataDatabase()
}()
// MARK: - Repositories
lazy var authRepository: AuthRepository = {
AuthRepositoryImpl(
remoteDataSource: AuthRemoteDataSource(apiClient: apiClient),
tokenStore: tokenStore
)
}()
lazy var feedRepository: FeedRepository = {
FeedRepositoryImpl(
remoteDataSource: FeedRemoteDataSource(apiClient: apiClient),
localDataSource: FeedLocalDataSource(database: database)
)
}()
lazy var userRepository: UserRepository = {
UserRepositoryImpl(
remoteDataSource: UserRemoteDataSource(apiClient: apiClient),
localDataSource: UserLocalDataSource(database: database)
)
}()
// MARK: - Use Cases
func makeLoginUseCase() -> LoginUseCase {
LoginUseCase(authRepository: authRepository)
}
func makeGetFeedUseCase() -> GetFeedUseCase {
GetFeedUseCase(feedRepository: feedRepository)
}
func makeUpdateProfileUseCase() -> UpdateProfileUseCase {
UpdateProfileUseCase(userRepository: userRepository)
}
// MARK: - ViewModels
func makeLoginViewModel() -> LoginViewModel {
LoginViewModel(loginUseCase: makeLoginUseCase())
}
func makeHomeViewModel() -> HomeViewModel {
HomeViewModel(getFeedUseCase: makeGetFeedUseCase())
}
func makeProfileViewModel() -> ProfileViewModel {
ProfileViewModel(
userRepository: userRepository,
updateProfileUseCase: makeUpdateProfileUseCase()
)
}
}
Usage in SwiftUI
// App.swift
@main
struct MyApp: App {
private let container = AppContainer()
var body: some Scene {
WindowGroup {
ContentView(container: container)
}
}
}
// ContentView.swift
struct ContentView: View {
private let container: AppContainer
init(container: AppContainer) {
self.container = container
}
var body: some View {
NavigationStack {
LoginView(viewModel: container.makeLoginViewModel())
}
}
}
// LoginView.swift
struct LoginView: View {
@StateObject private var viewModel: LoginViewModel
init(viewModel: LoginViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
// UI code
}
}
Testing with Container
// Define a lightweight container contract for testability
protocol AppContainerType {
func makeLoginViewModel() -> LoginViewModel
}
// Production container
final class AppContainer: AppContainerType {
// Infrastructure
lazy var tokenStore: TokenStore = KeychainTokenStore()
lazy var apiClient: APIClient = URLSessionAPIClient(
baseURL: Configuration.apiBaseURL,
tokenStore: tokenStore
)
// Repositories
lazy var authRepository: AuthRepository = AuthRepositoryImpl(
remoteDataSource: AuthRemoteDataSource(apiClient: apiClient),
tokenStore: tokenStore
)
// Use cases
func makeLoginUseCase() -> LoginUseCase {
LoginUseCase(authRepository: authRepository)
}
// ViewModels
func makeLoginViewModel() -> LoginViewModel {
LoginViewModel(loginUseCase: makeLoginUseCase())
}
}
// Test container with mock dependencies
final class TestAppContainer: AppContainerType {
private let authRepository: AuthRepository
init(authRepository: AuthRepository = MockAuthRepository()) {
self.authRepository = authRepository
}
func makeLoginViewModel() -> LoginViewModel {
let useCase = LoginUseCase(authRepository: authRepository)
return LoginViewModel(loginUseCase: useCase)
}
}
// In tests
@Test
func testLogin() async throws {
let container = TestAppContainer()
let viewModel = container.makeLoginViewModel()
await viewModel.login(email: "test@example.com", password: "password")
#expect(viewModel.isAuthenticated == true)
}
Android (Kotlin) - Hilt
Hilt is the recommended DI framework for Android, built on top of Dagger.
Setup
// build.gradle.kts (app module)
plugins {
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
// If your project uses kapt instead of ksp, enable kapt and switch the compiler dependency accordingly
// kotlin("kapt")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.50")
ksp("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
}
Application Class
// MyApplication.kt
@HiltAndroidApp
class MyApplication : Application()
Network Module
// app/core/di/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient
): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create())
.build()
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService =
retrofit.create(ApiService::class.java)
@Provides
@Singleton
fun provideAuthInterceptor(tokenStore: TokenStore): AuthInterceptor =
AuthInterceptor(tokenStore)
}
Database Module
// app/core/di/DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(
@ApplicationContext context: Context
): AppDatabase = Room.databaseBuilder(
context,
AppDatabase::class.java,
"app_database"
).build()
@Provides
@Singleton
fun providePostDao(database: AppDatabase): PostDao =
database.postDao()
@Provides
@Singleton
fun provideUserDao(database: AppDatabase): UserDao =
database.userDao()
}
Repository Module
// app/core/di/RepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(
impl: AuthRepositoryImpl
): AuthRepository
@Binds
@Singleton
abstract fun bindFeedRepository(
impl: FeedRepositoryImpl
): FeedRepository
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
ViewModel Injection
// app/features/login/presentation/screen/LoginViewModel.kt
@HiltViewModel
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase
) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
fun login(email: String, password: String) {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true)
loginUseCase(email, password)
.onSuccess { user ->
_state.value = LoginState(user = user)
}
.onFailure { error ->
_state.value = _state.value.copy(
loading = false,
error = error.message
)
}
}
}
}
Usage in Compose
// app/features/login/presentation/screen/LoginScreen.kt
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel()
) {
val state by viewModel.state.collectAsState()
// UI code
}
Testing with Hilt
// Option A: Pure JVM unit test (recommended for ViewModel logic)
class LoginViewModelTest {
@Test
fun `login with valid credentials succeeds`() = runTest {
val fakeUseCase = FakeLoginUseCase(success = true)
val viewModel = LoginViewModel(fakeUseCase)
viewModel.login("test@example.com", "password")
assertEquals(true, viewModel.state.value.user != null)
}
}
// Option B: Hilt test (use when you need injected graph / integration coverage)
@HiltAndroidTest
class LoginViewModelHiltTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var loginUseCase: LoginUseCase
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun `login use case is injected`() {
// Assert injection / run an integration-style test as needed
assertEquals(true, ::loginUseCase.isInitialized)
}
}
// Test module to replace dependencies (for Option B)
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
abstract class TestRepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(
impl: MockAuthRepository
): AuthRepository
}
Flutter (Dart) - get_it
get_it is a simple service locator. Use it only at the composition root (dependency registration and widget wiring). Keep constructor injection inside your domain/data/presentation classes.
Setup
# pubspec.yaml
dependencies:
get_it: ^7.6.4
Dependency Registration
// lib/core/di/injection.dart
final sl = GetIt.instance;
Future<void> setupServiceLocator() async {
// Infrastructure
sl.registerLazySingleton<Dio>(() => Dio(
BaseOptions(
baseUrl: Env.baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
),
));
sl.registerLazySingleton<TokenStore>(() => SecureTokenStore());
sl.registerLazySingleton<Database>(() => HiveDatabase());
// Data Sources
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSource(sl())
);
sl.registerLazySingleton<FeedRemoteDataSource>(
() => FeedRemoteDataSource(sl())
);
sl.registerLazySingleton<FeedLocalDataSource>(
() => FeedLocalDataSource(sl())
);
// Repositories
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: sl(),
tokenStore: sl(),
),
);
sl.registerLazySingleton<FeedRepository>(
() => FeedRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
),
);
sl.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
),
);
// Use Cases
sl.registerLazySingleton(() => LoginUseCase(sl()));
sl.registerLazySingleton(() => GetFeedUseCase(sl()));
sl.registerLazySingleton(() => UpdateProfileUseCase(sl()));
// Blocs (Factory - new instance each time)
sl.registerFactory(() => LoginBloc(loginUseCase: sl()));
sl.registerFactory(() => HomeBloc(getFeedUseCase: sl()));
sl.registerFactory(() => ProfileBloc(
userRepository: sl(),
updateProfileUseCase: sl(),
));
}
Initialization
// main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await setupServiceLocator();
runApp(const MyApp());
}
Usage with BlocProvider
// lib/features/login/presentation/login_page.dart
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<LoginBloc>(),
child: const LoginView(),
);
}
}
Testing with get_it
// test/helpers/test_injection.dart
final testSl = GetIt.instance;
Future<void> setupTestDI() async {
testSl.reset();
// Register mocks
testSl.registerLazySingleton<AuthRepository>(() => MockAuthRepository());
testSl.registerLazySingleton(() => LoginUseCase(testSl()));
testSl.registerFactory(() => LoginBloc(loginUseCase: testSl()));
}
// In test
void main() {
setUpAll(() async {
await setupTestDI();
});
test('login with valid credentials succeeds', () async {
final bloc = testSl<LoginBloc>();
bloc.add(const LoginSubmitted(
email: 'test@example.com',
password: 'password',
));
await expectLater(
bloc.stream,
emitsInOrder([
isA<LoginLoading>(),
isA<LoginSuccess>(),
]),
);
});
}
Best Practices
1. Use Constructor Injection
Always inject dependencies through constructors, not properties.
2. Depend on Abstractions
Inject interfaces/protocols, not concrete implementations.
// GOOD
class LoginViewModel {
private let authRepository: AuthRepository // Protocol
}
// BAD
class LoginViewModel {
private let authRepository: AuthRepositoryImpl // Concrete class
}
3. Avoid Service Locator in Business Logic
Service locators are acceptable at composition root but not in domain or data layers.
4. Use Scopes Appropriately
- Singleton: Shared state (repositories, network clients)
- Factory/Transient: Stateful components (ViewModels, Blocs)
5. Keep Dependency Graph Shallow
Avoid deep dependency chains. If a class needs 5+ dependencies, consider refactoring.
Summary
| Platform | Recommended Approach | Scope Management |
|---|---|---|
| iOS | Manual DI Container | lazy var for singletons, factory methods for transients |
| Android | Hilt | @Singleton, @ViewModelScoped |
| Flutter | get_it (service locator at composition root) | registerLazySingleton, registerFactory |
Dependency injection improves code quality by making dependencies explicit, testable, and flexible.