Blog Infos
Author
Published
Topics
, , , ,
Published

 

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()`:

  1. Type is reified → The compiler generates code that captures `UserRepository::class`
  2. getKoin() is called → Returns the global Koin application instance
  3. Registry lookup → Koin searches its registry for a binding of type `UserRepository`
  4. 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:

  1. `KoinComponent` is platform-specific
  2. Only Android needs this bridge iOS has its own DI approach
  3. `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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Sharing code across platforms is a wonderful superpower. But sometimes, sharing 100% of your codebase isn’t the goal. Maybe you’re migrating existing apps to multiplatform, maybe you have platform-specific libraries or APIs you want to…
Watch Video

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Developer

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform ...

When sharing isn’t caring: Using platform-specific code in Kotlin Multiplatform

Russell Wolf
Kotlin Multiplatform Deve ...

Jobs

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

Menu