Mobile Development 29 min read

Mastering Android Dependency Injection: Hilt vs Koin Explained

This comprehensive guide demystifies Android dependency injection, covering core concepts, manual implementation, the role of composition roots, and a detailed comparison of Hilt and Koin—including their architectures, advantages, trade‑offs, and practical tips for choosing the right framework in real‑world projects.

AndroidPub
AndroidPub
AndroidPub
Mastering Android Dependency Injection: Hilt vs Koin Explained

What is Dependency Injection (DI)

Dependency Injection is a design pattern that supplies a class with the instances of the other classes it depends on, instead of letting the class create them itself. This decouples components, improves testability and makes lifecycle management explicit.

Anti‑example: Direct Instantiation

A repository that creates its own HttpClient and Database instances is tightly coupled to concrete implementations.

class Repository {
    private val httpClient = HttpClient()
    private val database = Database()

    suspend fun fetchData(id: String): Data {
        val rawData = httpClient.get("/api/data/$id")
        database.save(rawData)
        return rawData
    }
}
In this example the Repository uses HttpClient and Database , but it obtains them by direct instantiation, so no injection occurs.

Tight coupling between Repository, HttpClient and Database.

Difficult testing : a mock cannot replace the real HttpClient without changing the class.

Poor flexibility : swapping the network library or database implementation requires code changes in the repository.

Constructor Injection

Refactor the class to receive its dependencies through the constructor:

class Repository(
    private val httpClient: HttpClient,
    private val database: Database
) {
    suspend fun fetchData(id: String): Data {
        val rawData = httpClient.get("/api/data/$id")
        database.save(rawData)
        return rawData
    }
}
Now the Repository declares the dependencies it needs, and a DI container (or manual code) will provide them.

Testing becomes trivial – you can pass mock implementations.

Swapping implementations is as easy as providing a different instance.

The dependency graph is explicit and discoverable.

Lifecycle management is controlled by the code that creates the instances.

Three Main Injection Styles

1. Constructor Injection

This is the preferred method. All required dependencies are listed in the class constructor, guaranteeing that the object is fully initialized after creation.

class PaymentProcessor(
    private val gateway: PaymentGateway,
    private val receipts: ReceiptGenerator,
    private val analytics: Analytics
) {
    suspend fun process(amount: Money): Receipt {
        val result = gateway.charge(amount)
        analytics.track("payment_processed")
        return receipts.create(result)
    }
}

2. Parameter / Method Injection

When a dependency is only needed for a specific method, pass it as a parameter instead of storing it as a field.

class ReportGenerator {
    // ReportFormatter is only needed when generate() is called
    fun generate(data: ReportData, formatter: ReportFormatter): Report {
        return formatter.format(data)
    }
}

val pdf = ReportGenerator().generate(data, PdfFormatter())
val csv = ReportGenerator().generate(data, CsvFormatter())

3. Property / Field Injection

After an object is constructed, a DI framework can set its public properties. This is mainly used for Android framework classes such as Activity or Fragment, where the constructor cannot be controlled.

class CheckoutFragment : Fragment() {
    // Dependencies will be injected by the DI framework later
    lateinit var paymentProcessor: PaymentProcessor
    lateinit var cartRepository: CartRepository
}

Composition Root and Service Locator

Large applications often have a deep dependency graph. To avoid scattering object creation throughout the codebase, developers introduce a Composition Root – a single place responsible for assembling and providing all objects.

class AppContainer(private val context: Context) {
    // Network layer
    private val okHttpClient by lazy { OkHttpClient.Builder().addInterceptor(LoggingInterceptor()).build() }
    private val retrofit by lazy { Retrofit.Builder().baseUrl(BuildConfig.API_URL).client(okHttpClient).addConverterFactory(MoshiConverterFactory.create()).build() }
    private val apiService by lazy { retrofit.create(ApiService::class.java) }

    // Database layer
    private val database by lazy { Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build() }

    // Repositories exposed to the rest of the app
    val userRepository by lazy { UserRepository(apiService = apiService, userDao = database.userDao()) }
    val authRepository by lazy { AuthRepository(apiService = apiService, tokenStorage = EncryptedTokenStorage(context)) }
}

The Application class creates the container once, and any component (e.g., an Activity) retrieves the needed dependencies from it:

class MyApplication : Application() {
    lateinit var container: AppContainer
    override fun onCreate() {
        super.onCreate()
        container = AppContainer(this)
    }
}

class ProfileActivity : AppCompatActivity() {
    private val userRepository by lazy { (application as MyApplication).container.userRepository }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // use userRepository …
    }
}
Core insight: The composition root assembles dependencies; when a consumer pulls a dependency from this container, the pattern is a Service Locator . In pure DI the container would push the dependencies into the constructor instead.

Why Use DI Frameworks

Less repetitive code – declare how to create an object once.

Automatic scope and lifecycle handling (singletons, factories, view‑model scopes).

Compile‑time error detection (Hilt/Dagger) or runtime safety checks (Koin).

Improved testability – replace real implementations with mocks or fakes easily.

DI and SOLID Principles

Single Responsibility Principle (SRP)

Each class should have only one reason to change. By injecting dependencies, a class focuses on its core responsibility and does not manage the creation of its collaborators.

// Without DI – UserRepository creates its own API and DB instances (violates SRP)
class UserRepository {
    private val api = RetrofitClient.create()
    private val db = DatabaseProvider.instance()
    // …
}

// With DI – responsibilities are separated
class UserRepository(private val api: UserApi, private val db: UserDatabase) {
    // …
}

Dependency Inversion Principle (DIP)

High‑level modules should depend on abstractions, not concrete implementations. DI lets you inject interfaces, making the system loosely coupled and easier to swap implementations.

interface UserApi {
    suspend fun fetchUser(id: String): User
    suspend fun updateUser(user: User)
}

class UserRepository(private val api: UserApi) { /* … */ }

// Concrete implementations
class RetrofitUserApi : UserApi { /* … */ }
class MockUserApi : UserApi { /* … */ }

Service Locator vs. Pure DI

Pure DI (push model) : The container constructs objects and pushes dependencies into constructors. Classes are passive and only declare what they need.

Service Locator (pull model) : The container holds a registry; classes actively request (“pull”) the dependencies they need.

Both patterns can produce identical business‑logic classes, but the underlying mechanism differs, affecting compile‑time safety and tooling.

Hilt vs. Koin: Quick Start

Hilt (Google’s official solution)

Add the Hilt plugin and dependencies in build.gradle.kts.

// Project build.gradle.kts
plugins {
    id("com.google.dagger.hilt.android") version "x.x.x" apply false
}

// App build.gradle.kts
plugins {
    id("com.google.dagger.hilt.android")
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("com.google.dagger:hilt-android:x.x.x")
    ksp("com.google.dagger:hilt-compiler:x.x.x")
}

Annotate the Application class with @HiltAndroidApp.

@HiltAndroidApp
class NewsApplication : Application()

Mark injectable classes with @Inject on the constructor and provide third‑party objects via @Module and @Provides.

@Inject constructor(
    private val api: ArticleApi,
    private val dao: ArticleDao
)

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
    @Provides @Singleton fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
        Retrofit.Builder().baseUrl("...").client(okHttpClient).build()
}

Inject into Android components with @AndroidEntryPoint and by viewModels() for ViewModels.

@AndroidEntryPoint
class ArticleListActivity : AppCompatActivity() {
    private val viewModel: ArticleListViewModel by viewModels()
}

@HiltViewModel
class ArticleListViewModel @Inject constructor(
    private val repository: ArticleRepository
) : ViewModel()

Koin (Kotlin‑first lightweight option)

Add Koin dependencies (no plugin required).

dependencies {
    implementation("io.insert-koin:koin-android:x.x.x")
    implementation("io.insert-koin:koin-androidx-compose:x.x.x") // if using Compose
}

Define modules with module {} using single, factory, and viewModel definitions.

val networkModule = module {
    single { OkHttpClient.Builder().build() }
    single { Retrofit.Builder().baseUrl("...").client(get()).build() }
}

val repositoryModule = module {
    single { ArticleRepository(get(), get()) }
}

val viewModelModule = module {
    viewModel { ArticleListViewModel(get()) }
}

Start Koin in the Application class and load the modules.

class NewsApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@NewsApplication)
            modules(networkModule, repositoryModule, viewModelModule)
        }
    }
}

Inject dependencies in Android components using Koin delegates.

class ArticleListActivity : AppCompatActivity() {
    private val viewModel: ArticleListViewModel by viewModel()
}

class ArticleRepository(private val api: ArticleApi, private val dao: ArticleDao)

Decision Matrix (Summary)

Official recommendation : Hilt (Google) vs. Koin (none).

Compile‑time safety : Hilt provides it out of the box; Koin can achieve it with optional annotations or upcoming compiler plugins.

Kotlin Multiplatform support : Koin supports it natively; Hilt does not.

Learning curve : Hilt/Dagger is steeper; Koin’s DSL is gentle.

Build speed : Koin is generally faster because it avoids annotation processing.

Code intrusiveness : Hilt requires many annotations; Koin keeps business classes annotation‑free.

Ecosystem integration : Hilt integrates deeply with Jetpack; Koin integrates well but slightly less tightly.

Practical Advice

Prefer constructor injection everywhere; fall back to property injection only when the Android framework forces it.

Organise modules by concern (e.g., NetworkModule, DatabaseModule, RepositoryModule) to keep the codebase tidy.

Choose the smallest appropriate scope – not everything needs to be a singleton.

Avoid over‑abstracting; only create interfaces when you truly expect multiple implementations.

Document complex bindings (conditional providers, build‑type specific implementations) with clear comments.

Conclusion

Dependency injection separates “what a class needs” from “how those needs are created.” Whether you adopt Hilt for its compile‑time guarantees and Jetpack integration, or Koin for its lightweight Kotlin‑first DSL and multiplatform friendliness, embracing DI leads to more modular, testable, and maintainable Android applications.

Dependency Injection diagram
Dependency Injection diagram
AndroidKotlindependency-injectionHiltKoinDI Frameworks
AndroidPub
Written by

AndroidPub

Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.