Photo by Diana Polekhina on Unsplash

Multiplatform Dependency Injection in Kotlin

KMM Architecture #5

Marcin Piekielny
5 min readApr 2, 2023

--

In the KMM we can’t use well known Android DI solutions like Dagger or Hilt. This article shows alternative approaches which can be applied to provide Multiplatform objects to the native applications.

Single entry point

Regardless of the architecture, we should always create a single entry point for the shared codebase. A single entry point makes it easier to manage shared instances. We access them from the native application using public properties or methods.

// Shared Services
class DemoSdk(private val config: DemoConfig) {

val usersService: UsersService
get() = ...

val articlesService: ArticlesService
get() = ...
}

// Shared MVI
class DemoSdk(private val config: DemoConfig) {

val loginViewModel: LoginViewModel
get() = ...

val homeViewModel: HomeViewModel
get() = ...
}

Configuration

Additionally the entry point can accept a Config object as a constructor parameter. It holds an external configuration which can be set by the native application.

It is especially useful when we need to pass some platform specific configuration down to the KMM implementation. For example on the Android side we need a reference to the Context to init an SQL database or Shared Preferences. Config class can be implemented using the KMM expect/actual mechanism.

// commonMain
expect class DemoConfig {
val databaseName: String
}

// androidMain
actual data class DemoConfig(
actual val databaseName: String,
val context: Context,
)

// iosMain
actual data class DemoConfig(
actual val databaseName: String
)

Use in the Android app

Use of the KMM Entry Point in the Android App is very simple. All we need to do is create an instance of the DemoSdk class and store it as a singleton. It should be accessible from different parts of the app. To achieve it we can use Hilt, Dagger or do it manually like presented below.

class DemoApp : Application() {
lateinit var sdk: DemoSdk

override fun onCreate() {
val config = DemoConfig(
databaseName = "android_demo.db",
context = applicationContext,
)
sdk = DemoSdk(config)
}
}
@Composable
fun getDemoSdk(): DemoSdk {
val context = LocalContext.current
val app = context.applicationContext as DemoApp
return app.sdk
}
@Composable
fun LoginScreen() {
val demoSdk = getDemoSdk()
// Standard androidx viewModel function
val viewModel = viewModel { demoSdk.loginViewModel }
val state by viewModel.uiState.collectAsState()
LoginScreen(state)
}

Use in the iOS app

Situation on the iOS is equally simple as for the Android. Thanks to the single entry point concept we can access Multiplatform code in the exactly the same way on both platforms. Here we can also use a DI solution of our choice or do it manually like presented below.

class DemoSdkProvider {
static let shared = DemoSdkProvider()

let sdk: DemoSdk

private init() {
let config = DemoConfig(databaseName: "ios_demo.db")
self.sdk = DemoSdk(config)
}
}
func buildLoginController() -> UIViewController {
let demoSdk = DemoSdkprovider.shared.sdk
let viewModel = LoginViewModel(
viewModelDelegate: demoSdk.loginViewModel
)
let view = LoginView(viewModel: viewModel)
return UIViewHostingController(rootView: view)
}

Single entry point vs other approaches

In the Internet we can find KMM samples where single entry point is not in use. They often rely on the Koin library in both KMM and Android parts of the codebase.

The big downside of this approach is that Android and iOS apps access the shared code in a different way. Android is privileged as it can use Koin modules directly. iOS app, on the other hand, doesn’t have a Koin support and requires extra helper functions to start Koin and get objects from it.

In order to keep it consistent I recommend to always introduce a single entry point class for the KMM part. Thanks to this both Android and iOS apps access the shared code exactly the same way.

The Android app is also not forced to use the Koin library. An instance of DemoSdk can be managed in different ways, including adding it to the Hilt or Dagger @Module

Plain Kotlin DI

For simple cases it is perfectly fine to build a simple custom DI solution using just a Kotlin language features.

  • Objects which can be used by the native app are public properties.
  • Objects which are used only inside the KMM codebase are private or internal properties.
  • When we need to define a Factory for a class we implement a get() for a given property.
  • When we need to define a Singleton we implement a lazy delegate for a given property.
  • Config object can be used to create configurable instances.
class DemoSdk(private val config: DemoConfig) {

// It can be accessed by the native app
val usersService: UsersService
get() = UsersService(usersRepository)

val articlesService: ArticlesService
get() = ArticlesService(articlesRepository, usersRepository)

// It can be accessed only inside the KMM module
private val usersRepository: UsersRepository by lazy {
UsersRepository(usersApi)
}

// lazy works as a Singleton
private val articlesRepository: ArticlesRepository by lazy {
ArticlesRepository(articlesApi)
}

// get() works as a Factory
private val usersApi: UsersApi
get() = UsersApi(httpClient)

private val articlesApi: ArticlesApi
get() = ArticlesApi(httpClient)

private val httpClient: HttpClient by lazy {
createHttpClient(config)
}
}

Plain Kotlin DI — submodules

When we split our Multiplatform implementation into submodules and use shared module as an Umbrella we should also split the DI structure.

  • Each submodule defines its own entry point called *Module
  • Module can depend on other Modules and get objects from them.
  • Root DemoSdk entry point combines all the Modules in one place and keeps them as Singletons.
  • DemoSdk can pass Config to the Modules if they need it.
class UsersModule(private val coreModule: CoreModule) {

val usersService: UsersService
get() = UsersService(...)

...

private val usersApi: UsersApi
get() = UsersApi(coreModule.httpClient)
}
class DemoSdk(private val config: DemoConfig) {

val usersModule: UsersModule by lazy {
UsersModule(coreModule)
}

val coreModule: CoreModule by lazy {
CoreModule(config)
}
}

Koin DI

We can also choose to use a previously mentioned Koin library to manage our Multiplatform objects. In contrast to Dagger and Hilt, Koin has a direct support for the KMM projects.

  • DemoSdk creates an instance of the KoinApplication and attaches Koin modules to it.
  • DemoSdk exposes public properties, same as for the Plain Kotlin DI.
  • Each public property uses the KoinApplication to get a given object.
  • Config is also attached to the KoinApplication as a Koin module.
class DemoSdk(config: DemoConfig) {
private val configModule = module {
factory { config }
}

private val koinApplication = KoinApplication
.init()
.modules(demoSdkModule, configModule)

val usersService: UsersService
get() = getFromKoin()

val articlesService: ArticlesService
get() = getFromKoin()

private inline fun <reified T> getFromKoin(): T {
return koinApplication.koin.get()
}
}

⚠️ Avoid using standard startKoin function when using Koin in the shared KMM SDK. startKoin registers a global instance of Koin and we want to scope it only to the KMM part so the Native app doesn’t have to rely on this library.

Koin DI — submodules

When we split our KMM codebase into submodules and the shared module as an Umbrella we can also split our Koin setup.

  • DemoSdk exposes public properties with objects, same as before.
  • Each submodule defines its own Koin module.
  • DemoSdk creates an instance of the KoinApplication and attaches all the submodules to it.
val usersModule = module {
factoryOf(::UsersService)
...
}
class DemoSdk(config: DemoConfig) {
private val configModule = module {
factory { config }
}

private val koinApplication = KoinApplication
.init()
.modules(
usersModule,
articlesModule,
coreModule,
configModule,
)

val usersService: UsersService
get() = getFromKoin()

val articlesService: ArticlesService
get() = getFromKoin()

private inline fun <reified T> getFromKoin(): T {
return koinApplication.koin.get()
}
}

--

--