Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 028 / 100

Koin vs Hilt: Android & Kotlin best practices explained

Two DI options for Android.

Expona Admin6 min read

Introduction

If you're building Android apps in Kotlin, you've likely encountered the decision between Koin and Hilt for dependency injection. Both solve the same problem—wiring up your app's dependencies cleanly—but they take fundamentally different approaches. In this article, we'll explore Koin vs Hilt: Android & Kotlin best practices explained, examining when to reach for each tool and how to use them effectively in production codebases.

Understanding dependency injection in Android

Dependency injection (DI) isn't new, but Android made it a first-class concern relatively late. Before tools like Hilt and Koin became standard, many teams either built custom service locators or relied on framework lifecycle management. Both approaches led to testability nightmares and tight coupling between components.

A DI container solves this by managing object creation and lifetime, injecting dependencies where needed. The choice between Hilt and Koin often comes down to philosophy: do you want compile-time safety and official backing, or runtime flexibility and simplicity?

Hilt: compile-time, official, Dagger-based

Hilt is built on top of Dagger 2 and is Google's officially sanctioned dependency injection framework for Android. It generates code at compile time, which means many errors surface during the build rather than at runtime. If your app uses Hilt incorrectly, you'll know before shipping.

Hilt uses annotations to declare components and bindings:

kotlin
@HiltAndroidApp
class MyApplication : Application()

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject
    lateinit var userRepository: UserRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // userRepository is automatically injected
    }
}

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    @Provides
    @Singleton
    fun provideUserRepository(api: UserApi): UserRepository {
        return UserRepository(api)
    }

    @Provides
    @Singleton
    fun provideUserApi(): UserApi {
        return UserApi("https://api.example.com")
    }
}

The main advantage: Hilt catches wiring errors at compile time. If you forget to provide a dependency, the build fails immediately. This is invaluable in large teams where mistakes might otherwise reach production.

Hilt integrates deeply with Android's component lifecycle. It provides predefined scopes like @Singleton, @ActivityScoped, and @FragmentScoped, which align with how Android components work. This means fewer surprises when your Activity is destroyed and recreated.

The trade-off: Hilt requires more boilerplate. You'll write more annotations, and the learning curve steeper. Compile times also increase because Hilt generates code for every @AndroidEntryPoint class.

Koin: simple Kotlin DSL, runtime resolution

Koin takes a different path. It's a lightweight, Kotlin-first service locator that resolves dependencies at runtime using a fluent DSL. No code generation, no annotation processing.

kotlin
val appModule = module {
    single { UserApi("https://api.example.com") }
    single { UserRepository(get()) }
}

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

class MainActivity : AppCompatActivity() {
    private val userRepository: UserRepository by inject()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // userRepository is resolved lazily on first access
    }
}

Koin's strength is simplicity. The module declaration reads like a grocery list: define what you need, in order, using get() to resolve dependencies. This is particularly appealing to teams coming from frameworks like Spring where dependency configuration feels familiar.

Koin also shines in Kotlin Multiplatform projects. Because it doesn't rely on Android-specific annotation processing, you can use the same DI setup in shared code, on Android, iOS (via Kotlin Native), and the backend. This is a significant advantage if you're building multiplatform libraries or cross-platform features.

The trade-off: Koin resolves dependencies at runtime, so errors surface only when code actually runs. If you forget to define a binding, you'll get a runtime exception during test or production use. For large teams, this can be risky.

Hilt catches errors at build time

This point deserves its own section because it's the most significant operational difference. Consider a scenario: you refactor a service's constructor to require a new dependency.

With Hilt:

kotlin
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
    @Singleton
    fun provideAnalyticsService(
        context: Context,
        logger: Logger  // New dependency added
    ): AnalyticsService {
        return AnalyticsService(context, logger)
    }
}

If you forget to provide Logger, the build fails. You fix it before committing. Safe.

With Koin:

kotlin
val appModule = module {
    single { AnalyticsService(get(), get()) }  // Expects Logger, but not defined yet
}

The build succeeds. The app crashes when AnalyticsService is first resolved, possibly in production. This is why Koin requires stricter testing discipline: you must test all initialization paths and dependency resolution in your test suite.

In practice, we've seen teams mitigate this by running comprehensive integration tests before release, but Hilt's compile-time guarantee is stronger.

Koin works in Kotlin Multiplatform

If you're building a Kotlin Multiplatform (KMP) project, Hilt is a non-starter for shared code. Hilt is Android-specific, built on Android's component model and annotation processing.

Koin, however, works everywhere Kotlin runs. Here's a typical setup:

kotlin
// shared/src/commonMain/kotlin
val commonModule = module {
    single { UserRepository(get()) }
    single { Logger() }
}

// androidApp/src/main/kotlin
val androidModule = module {
    single { AndroidUserApi("https://api.example.com") }
}

// iosApp/src/main/kotlin
val iosModule = module {
    single { IosUserApi("https://api.example.com") }
}

// In your Android Application
startKoin {
    modules(commonModule, androidModule)
}

For teams building KMP libraries or shared business logic, Koin removes the friction of maintaining two separate DI setups. You define your core domain dependencies once in shared code, then provide platform-specific implementations in each target.

Common mistakes

With Hilt: Over-scoping. Developers often mark services as @ActivityScoped out of caution, leading to duplicate instances and memory leaks. If a dependency doesn't need to live as long as an Activity, use @Singleton or a narrower scope. Another common mistake is relying on @InstallIn(SingletonComponent::class) for everything; scope your modules appropriately to avoid lifetime mismatches.

With Koin: Lazy injection abuse. Koin's by inject() is lazy, meaning the dependency isn't resolved until accessed. This can hide initialization errors until runtime. Always test your module definitions in tests to catch missing bindings early. Also, avoid circular dependencies; Koin doesn't prevent them, so your app will crash when both services try to resolve each other.

Both: Mixing DI frameworks in the same project. If your team uses Hilt for Activities but Koin for ViewModels, you've created maintenance debt. Pick one, document it, and stick with it.

Practical comparison: which should you use?

Use Hilt if:

  • You're building a standard Android app and want compile-time safety.
  • Your team includes junior developers who benefit from the compiler catching mistakes.
  • You're already deep in the Android ecosystem and don't need cross-platform DI.
  • You prefer official tooling with long-term support guarantees.

Use Koin if:

  • You're building a Kotlin Multiplatform project.
  • Your team values simplicity and rapid iteration over compile-time checks.
  • You want minimal build-time overhead.
  • You're prototyping or in an early-stage project where flexibility matters more than safety.

In mid-to-large production teams, we typically recommend Hilt for its compile-time guarantees. But if you're doing KMP, Koin is the pragmatic choice.

Key takeaways

  • Hilt is compile-time, official, and Dagger-based; it catches wiring errors at build time, making it safer for large teams.
  • Koin is a lightweight, Kotlin DSL–based service locator that resolves dependencies at runtime, offering simplicity and KMP support.
  • Hilt integrates deeply with Android's component lifecycle, while Koin is platform-agnostic and works across Kotlin targets.
  • Compile-time safety matters in production: Hilt prevents entire classes of bugs, but requires more boilerplate and longer build times.
  • Koin's simplicity is appealing, but demands stronger test coverage to catch missing bindings early.
  • If you're doing Kotlin Multiplatform, Koin is the clear choice. For standard Android, Hilt is the safer bet.
  • Never mix both frameworks in one project; pick one and establish it as a team standard.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles