
How to seamlessly connect two dependency injection worlds in a Kotlin Multiplatform project:
If you’re building a KMP application with Koin handling shared dependencies and Hilt managing Android-specific code, you’ve likely faced a critical challenge: these two DI frameworks live in separate worlds and don’t talk to each other. This article reveals the elegant bridge pattern that connects them.
The Problem: Two Dependency Graphs That Don’t Talk
- You’re building a Kotlin Multiplatform application. Your shared business logic lives in `commonMain`, and you’re using Koin for dependency injection because it’s lightweight, pure Kotlin, and works across all platforms. Your Android-specific UI layer uses Hilt because of its compile-time safety, Android lifecycle integration, and it’s Google’s recommended approach.
- Here’s the challenge: Your Android Native ViewModels need to inject repositories that are defined and managed by Koin in your KMP module.
- The problem is fundamental: Koin and Hilt maintain separate dependency graphs. They don’t communicate. When Hilt tries to provide a dependency, it only knows about dependencies registered in Hilt modules. It has no idea about Koin’s registry.
┌────────────────────────────────┐ │ Android Native (Hilt) │ │ ViewModel needs Repository │ ❌ Can't access └────────────────────────────────┘ ↓ No connection! ┌────────────────────────────────┐ ↑ │ KMP Module (Koin) │ ❌ Can't provide │ Repository lives here │ └────────────────────────────────┘
This isn’t just theoretical it’s a real production challenge when you can’t migrate everything overnight.
The Solution: The Bridge Pattern
- The solution is elegantly simple: create a bridge object in `androidMain` that implements Koin’s `KoinComponent` interface and exposes functions that Hilt can call to retrieve Koin-managed dependencies.
┌────────────────────────────────┐ │ Android Native (Hilt) │ │ ViewModel needs Repository │ └────────────────────────────────┘ ↓ Hilt provides ┌────────────────────────────────┐ │ Hilt Module │ │ Calls bridge │ └────────────────────────────────┘ ↓ ┌────────────────────────────────┐ │ Bridge (androidMain) │ ← The key piece! │ Implements KoinComponent │ └────────────────────────────────┘ ↓ Calls Koin's get() ┌────────────────────────────────┐ │ KMP Module (Koin) │ │ Repository registered here │ └────────────────────────────────┘
- Key Insight: The bridge lives in `androidMain`, giving it access to both the KMP interfaces and Android-specific DI capabilities.
Implementation: Step by Step
Let’s implement this pattern with a simple example: a `UserRepository` that manages user data.
Step 1: Define the Contract in commonMain
- First, define your repository interface in the shared code:
kotlin// shared/src/commonMain/kotlin/com/example/data/UserRepository.kt
package com.example.data
import kotlinx.coroutines.flow.Flow
data class User(
val id: String,
val name: String,
val email: String
)
interface UserRepository {
suspend fun getUser(userId: String): Result<User>
suspend fun updateUser(user: User): Result<Unit>
fun observeUser(userId: String): Flow<User>
}
- Nothing special here just a standard interface with suspend functions and Flow.
Step 2: Implement and Register with Koin
- Create the implementation:
kotlin // shared/src/commonMain/kotlin/com/example/data/UserRepositoryImpl.kt
package com.example.data
import kotlinx.coroutines.flow.Flow
class UserRepositoryImpl(
private val apiService: ApiService,
private val database: UserDatabase
) : UserRepository {
override suspend fun getUser(userId: String): Result<User> {
return try {
val user = apiService.fetchUser(userId)
database.saveUser(user)
Result.success(user)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun updateUser(user: User): Result<Unit> {
return try {
apiService.updateUser(user)
database.updateUser(user)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override fun observeUser(userId: String): Flow<User> {
return database.observeUser(userId)
}
}
- Register with Koin:
kotlin // shared/src/commonMain/kotlin/com/example/di/DataModule.kt
package com.example.di
import com.example.data.UserRepository
import com.example.data.UserRepositoryImpl
import org.koin.dsl.module
val dataModule = module {
single<UserRepository> {
UserRepositoryImpl(
apiService = get(),
database = get()
)
}
}
- At this point, Koin knows how to provide `UserRepository`. Any code in KMP can call `get<UserRepository>()` from a `KoinComponent`.
Step 3: Create the Bridge in androidMain
- Here’s where the magic happens.
- Create a bridge object in `androidMain`:
kotlin // shared/src/androidMain/kotlin/com/example/di/KoinBridge.kt
package com.example.di
import com.example.data.UserRepository
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
object KoinBridge : KoinComponent {
fun getUserRepository(): UserRepository = get()
}
Let’s dissect what’s happening here:
Why `object`?
- Using Kotlin’s `object` keyword creates a singleton. The bridge is stateless it just provides access to Koin so we don’t need multiple instances.
What is `KoinComponent`?
- `KoinComponent` is a marker interface provided by Koin. When you implement it, you gain access to Koin’s dependency resolution functions, primarily `get()`.
- Under the hood, `KoinComponent` provides this extension function:
inline fun <reified T : Any> KoinComponent.get(): T {
return getKoin().get(T::class)
}
- The `reified` keyword allows Kotlin to preserve type information at runtime. When you call `get<UserRepository>()`, Kotlin knows you want a `UserRepository` and can pass that type to Koin.
Here’s what happens when you call `get()`:
- Type is reified → The compiler generates code that captures `UserRepository::class`
- getKoin() is called → Returns the global Koin application instance
- Registry lookup → Koin searches its registry for a binding of type `UserRepository`
- Instance returned → If found (and it’s a `single`), returns the cached instance
Why a named function?
We could just expose `get()`, but having an explicitly named function like `getUserRepository()` provides:
- Clear intent: Anyone reading the code knows exactly what this provides
- Type safety: The return type is explicit
Why androidMain?
The bridge must live in `androidMain`, not `commonMain`, because:
- `KoinComponent` is platform-specific
- Only Android needs this bridge iOS has its own DI approach
- `androidMain` has access to both common interfaces and Android-specific types
Step 4: Create a Hilt Module in Android Native
- Now, in your Android app module (pure Android, not KMP), create a Hilt module that uses the bridge:
kotlin // app/src/main/java/com/example/android/di/RepositoryModule.kt
package com.example.android.di
import com.example.data.UserRepository
import com.example.di.KoinBridge
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
@Provides
@Singleton
fun provideUserRepository(): UserRepository {
return KoinBridge.getUserRepository()
}
}
What Hilt generates at compile time
- When you build your project, Hilt’s annotation processor generates code like this (simplified):
public final class RepositoryModule_ProvideUserRepositoryFactory
implements Factory<UserRepository> {
private static volatile UserRepository instance;
@Override
public UserRepository get() {
if (instance == null) {
synchronized (this) {
if (instance == null) {
instance = RepositoryModule.provideUserRepository();
}
}
}
return instance;
}
}
- This is a typical double-checked locking singleton pattern. Hilt ensures that `provideUserRepository()` is only called once, and the result is cached.
Step 5: Inject in Your Android ViewModel
- Finally, use it in your Android ViewModel:
package com.example.android.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.data.User
import com.example.data.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user
fun loadUser(userId: String) {
viewModelScope.launch {
userRepository.getUser(userId).onSuccess { user ->
_user.value = user
}
}
}
fun updateUser(user: User) {
viewModelScope.launch {
userRepository.updateUser(user)
}
}
}
The beauty of this pattern:
- The ViewModel has no idea that `UserRepository` comes from Koin in the KMP module. From its perspective, it’s just another Hilt-provided dependency. Complete abstraction.
Job Offers
The Complete Flow: From UI to KMP and Back
- Let’s trace what happens when a user loads their profile:
[1] User opens profile screen
↓
[2] UserViewModel.loadUser("user123") is called
↓
[3] ViewModel needs UserRepository
→ Constructor injection: @Inject constructor(userRepository: UserRepository)
↓
[4] Hilt Resolution Process
├─ Hilt sees: "I need UserRepository"
├─ Searches its dependency graph
├─ Finds: RepositoryModule.provideUserRepository()
├─ Checks @Singleton cache
│ ├─ If cached: returns immediately
│ └─ If not: calls provider function
↓
[5] Provider Function Calls Bridge
└─ RepositoryModule.provideUserRepository() executes
└─ Calls: KoinBridge.getUserRepository()
↓
[6] Bridge Calls Koin
└─ KoinBridge.getUserRepository() executes
└─ Calls: get<UserRepository>() [Koin's get()]
↓
[7] Koin Resolution Process
├─ Koin searches its registry
├─ Finds: single<UserRepository> { UserRepositoryImpl(…) }
├─ Checks if instance exists (single scope)
│ ├─ If exists: returns cached instance
│ └─ If not:
│ ├─ Resolves dependencies (ApiService, UserDatabase)
│ ├─ Creates: UserRepositoryImpl(apiService, database)
│ ├─ Caches instance
│ └─ Returns instance
↓
[8] Instance Flows Back
└─ UserRepositoryImpl → Bridge → Hilt Provider → Hilt Cache → ViewModel
↓
[9] Repository Method Execution
└─ userRepository.getUser("user123") executes
├─ Calls apiService.fetchUser("user123")
├─ Saves to database.saveUser(user)
└─ Returns Result.success(user)
↓
[10] Result Handled in ViewModel
└─ onSuccess { user -> _user.value = user }
└─ UI observes StateFlow and updates
If you have any questions, just drop a comment, and I’ll get back to you ASAP.
Happy coding! 🎉
This article was previously published on proandroiddev.com



