Skip to main content

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

PlatformRecommended ApproachScope Management
iOSManual DI Containerlazy var for singletons, factory methods for transients
AndroidHilt@Singleton, @ViewModelScoped
Flutterget_it (service locator at composition root)registerLazySingleton, registerFactory

Dependency injection improves code quality by making dependencies explicit, testable, and flexible.