Skip to content

Dependency Injection and IoC

This is also a way to achieve decoupling.


Core Concepts

  • DIP (Dependency Inversion Principle): High-level modules should not depend on low-level modules; both should depend on abstractions (interfaces or abstract classes). Abstractions should not depend on details; details should depend on abstractions.

  • IoC (Inversion of Control): Instead of a class creating its own dependencies via new, those dependencies are provided externally — control is inverted.

  • Dependency Injection (DI): A way to implement IoC — dependencies are injected into the consumer (via constructor injection, property/field injection, or method injection).


Why Use It (Advantages)

  • Easier to test (can swap in mock or fake implementations)
  • Reduces coupling, improves maintainability and scalability
  • Clearer dependency boundaries and lifecycle management (especially with DI frameworks)

alt_text

Common Implementation Approaches & Examples

1) Manual Dependency Injection (Simple, for small projects / testing)

Idea: Pass dependencies via constructor (constructor injection). It’s straightforward and great for unit testing.

kotlin
// Interface and implementation
interface UserRepository {
    fun getUser(id: String): User
}

class UserRepositoryImpl(private val api: UserApi) : UserRepository {
    override fun getUser(id: String) = api.fetch(id)
}

// Consumer (ViewModel)
class UserViewModel(private val userRepository: UserRepository) : ViewModel() {
    fun load(id: String) { /* use userRepository */ }
}

// Assemble dependencies manually
class MyApplication : Application() {
    val userApi by lazy { UserApi() }
    val userRepository by lazy { UserRepositoryImpl(userApi) }
}

class MainActivity : AppCompatActivity() {
    private lateinit var viewModel: UserViewModel
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val repo = (application as MyApplication).userRepository
        viewModel = UserViewModel(repo) // Injected here
    }
}
  • Pros: Simple, explicit, easy to understand
  • Cons: In complex object graphs or with scope management, it becomes verbose (too many by lazy / factories)

Google illustrates this with an analogy: When you create a car, you must provide its engine, tires, etc. But the engine itself may depend on other parts — how deep does the nesting go? In Android MVVM, the ViewModel might depend on a Repository, which depends on a UseCase, which depends on a Service:

kotlin
class HomefeedViewModel(private val repo: HomefeedRepo)
class HomefeedRepo(private val useCase: HomefeedUseCase)
class HomefeedUseCase(private val service: HomefeedService)
class HomefeedService

class HomefeedViewModelFactory(
    private val homefeedRepo: HomefeedRepo,
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(HomefeedViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return HomefeedViewModel(homefeedRepo) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Creating the ViewModel:

kotlin
// Deeply nested "new" calls — complex and hard to maintain
val homefeedRepo = HomefeedRepo(HomefeedUseCase(HomefeedService()))
val factory = HomefeedViewModelFactory(homefeedRepo)
val viewModel = ViewModelProvider(this, factory)[HomefeedViewModel::class.java]

2) Service Locator (Factory / Registry) — ⚠️ Controversial Pattern

Idea: Use a global registry to store and retrieve dependencies. Simplifies setup but hides dependencies (hurts testability and clarity).

kotlin
object ServiceLocator {
    private val singletons = mutableMapOf<Class<*>, Any>()
    fun <T : Any> register(clazz: Class<T>, impl: T) { singletons[clazz] = impl }
    @Suppress("UNCHECKED_CAST")
    fun <T> get(clazz: Class<T>) = singletons[clazz] as T
}

// Initialization (Application)
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        ServiceLocator.register(UserRepository::class.java, UserRepositoryImpl(UserApi()))
    }
}

// Usage
class SomeClass {
    private val repo: UserRepository = ServiceLocator.get(UserRepository::class.java)
}

Note: Service Locator hides dependencies and introduces global state — generally not recommended.


Why: Compile-time injection, excellent performance, lifecycle-aware (Hilt). Most widely used in production Android apps.

Gradle (simplified):

gradle
implementation "com.google.dagger:hilt-android:2.x"
kapt "com.google.dagger:hilt-android-compiler:2.x"

Example:

kotlin
@HiltAndroidApp
class MyApplication : Application()

interface UserRepository { fun getUser(id: String): User }

class UserRepositoryImpl @Inject constructor(
    private val api: UserApi
): UserRepository {
    override fun getUser(id: String) = api.fetch(id)
}

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideUserApi(): UserApi = UserApi()

    @Provides @Singleton
    fun provideUserRepository(api: UserApi): UserRepository =
        UserRepositoryImpl(api)
}

@HiltViewModel
class UserViewModel @Inject constructor(
    private val repo: UserRepository
) : ViewModel()

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val vm: UserViewModel by viewModels()
}

Key points:

  • @HiltAndroidApp: bootstraps Hilt
  • @Module + @Provides or @Binds: provide dependencies
  • @InstallIn: defines the scope (Singleton, Activity, ViewModel, etc.)
  • Constructor injection with @Inject is preferred

4) Lightweight Container: Koin (Kotlin DSL, Runtime Injection)

Idea: Use Kotlin DSL to declare dependencies and load them at runtime. Simple and readable — great for small/medium projects.

kotlin
val appModule = module {
    single { UserApi() }
    single<UserRepository> { UserRepositoryImpl(get()) }
    viewModel { UserViewModel(get()) }
}

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@MyApplication)
            modules(appModule)
        }
    }
}

class MainActivity : AppCompatActivity() {
    private val vm: UserViewModel by viewModel()
}

Pros: Simple, readable, quick to set up Cons: Runtime resolution overhead, less strict than Dagger/Hilt in large projects


How DIP Appears in Code

  • Define dependencies via interfaces (interface UserRepository)
  • Bind implementations to interfaces externally (e.g., module or factory)
  • Inject via constructors (class UserViewModel(private val repo: UserRepository))

→ High-level modules depend on abstractions, not concrete implementations.


Comparison of Injection Methods (Preferred Order)

  1. Constructor Injection ✅ — explicit, testable, and framework-friendly
  2. Property/Field Injection — acceptable in Activities/Fragments (used by Hilt)
  3. Method Injection — for special delayed injection cases
  4. Service Locator 🚫 — implicit, not test-friendly

Testing Strategies

  • Provide fake/mock implementations for interfaces
  • Replace modules in tests (Hilt: @TestInstallIn; Koin: load test modules)

Practical Recommendations

Project TypeRecommended Approach
Small app / POC / learningManual DI or Koin
Medium–large / productionHilt (or Dagger)
Performance critical / strict compile-time safetyDagger or Hilt
AvoidManual new inside dependent classes (breaks DIP)

Quick Checklist

  • Do you use interfaces to abstract key dependencies? ✅
  • Are dependencies exposed via constructor parameters? ✅
  • Are scopes managed (Singleton, Activity, ViewModel)? ✅
  • Are fake/test modules available for testing? ✅

Just something casual. Hope you like it.