Master Kotlin Multiplatform with Decompose — Part 2: Dependency Injection, Kodein, Koin

Yeldar Nurpeissov
8 min readJun 25, 2024

--

Let’s enhance our app by adding a Dependency Injection (DI) container.

This article is part of a series on Kotlin Multiplatform with Decompose. If you haven’t read the previous article, I recommend starting there.

This article covers:

  1. Preparing code for dependency injection.
  2. Manually injecting dependencies.
  3. Injecting dependencies with Kodein.
  4. Injecting dependencies with Koin.

Preparing code for dependency injection

Our components will have two types of dependencies:

  1. Component creation and interaction dependencies (e.g., componentContext, IDs, lambdas) should be passed from the parent component using a factory method.
  2. Information and business logic dependencies (e.g., interactors, use cases, repositories) can be injected.

Create a Repository interface and its implementation

Previously, we stored the list of posts within the component. Now, let’s move them to the Repository class.

interface PostRepository {
fun getAllPosts(): List<Post>
fun getPost(id: String): Post
}

class DefaultPostRepository : PostRepository {
private val posts = List(16) {
Post(
id = it.toString(),
title = "Title-#$it",
description = "Description-#$it",
author = "Author-#$it",
)
}

override fun getAllPosts(): List<Post> = posts

override fun getPost(id: String): Post = posts.first { it.id == id }
}

Create a Factory interface for creating DetailComponent

interface DetailComponent {
val model: Value<Post>

fun onBackPressed()

// 1
fun interface Factory {
operator fun invoke( // 2
componentContext: ComponentContext,
postId: String,
onFinished: () -> Unit,
): DetailComponent
}
}
  1. Create a Factory SAM interface with the sole responsibility of component creation.
  2. The invoke method will create the DetailComponent, accepting only dependencies from the parent component as parameters.

Create a Factory class for DefaultDetailComponent

class DefaultDetailComponent(
componentContext: ComponentContext,
postId: String, // 1
private val repository: PostRepository, // 2
private val onFinished: () -> Unit,
) : DetailComponent, ComponentContext by componentContext {

// 3
override val model: Value<Post> = MutableValue(repository.getPost(postId))

override fun onBackPressed() = onFinished()

// 4
class Factory(
private val repository: PostRepository,
) : DetailComponent.Factory {

override fun invoke(
componentContext: ComponentContext,
postId: String,
onFinished: () -> Unit,
): DetailComponent = DefaultDetailComponent( // 5
componentContext = componentContext,
postId = postId,
onFinished = onFinished,
repository = repository,
)
}
}
  1. To get detailed information about a post, we only need the postId.
  2. Declare the repository dependency.
  3. Retrieve the post from the repository using postId.
  4. Create a Factory class for DefaultDetailComponent, passing the repository through the constructor to the component.

Create a Factory interface for ListComponent creation

interface ListComponent {
val model: Value<List<Post>>

fun onPostClicked(post: Post)

// 1
fun interface Factory {
operator fun invoke(
componentContext: ComponentContext,
postClicked: (postId: String) -> Unit,
): ListComponent
}
}
  1. Create a Factory SAM interface for ListComponent, similar to the one for DetailComponent.

Create a Factory class for DefaultListComponent

class DefaultListComponent(
componentContext: ComponentContext,
repository: PostRepository, // 1
private val postClicked: (postId: String) -> Unit, // 2
) : ListComponent, ComponentContext by componentContext {

// 3
override val model: Value<List<Post>> = MutableValue(repository.getAllPosts())

override fun onPostClicked(post: Post) = postClicked(post.id)

// 4
class Factory(
private val repository: PostRepository
) : ListComponent.Factory {
override fun invoke(
componentContext: ComponentContext,
postClicked: (postId: String) -> Unit
): ListComponent {
return DefaultListComponent(
componentContext = componentContext,
postClicked = postClicked,
repository = repository,
)
}
}
}
  1. Declare the repository dependency.
  2. Pass post click handling to the parent component using only postId.
  3. Retrieve all posts from the repository.
  4. Create a Factory class for DefaultListComponent.

Create a Factory interface for RootComponent creation

interface RootComponent {
val stack: Value<ChildStack<*, Child>>

sealed interface Child {
class List(val component: ListComponent) : Child
class Detail(val component: DetailComponent) : Child
}

fun interface Factory {
operator fun invoke(componentContext: ComponentContext): RootComponent
}
}

Finally, create a Factory class for DefaultRootComponent

class DefaultRootComponent( // 1
componentContext: ComponentContext,
private val listComponentFactory: ListComponent.Factory,
private val detailComponentFactory: DetailComponent.Factory,
) : RootComponent, ComponentContext by componentContext {

private val nav = StackNavigation<Config>()

override val stack: Value<ChildStack<*, RootComponent.Child>> = childStack(
source = nav,
serializer = Config.serializer(),
initialConfiguration = Config.List,
handleBackButton = true,
childFactory = ::child,
)

private fun child(
config: Config,
componentContext: ComponentContext
): RootComponent.Child = when (config) {
Config.List -> RootComponent.Child.List(
listComponentFactory( // 2
componentContext = componentContext,
postClicked = { postId -> nav.pushNew(Config.Detail(postId)) }
)
)

is Config.Detail -> RootComponent.Child.Detail(
detailComponentFactory( // 3
componentContext = componentContext,
postId = config.postId,
onFinished = { nav.pop() },
)
)
}


@Serializable
private sealed interface Config {
@Serializable
data object List : Config

@Serializable
data class Detail(val postId: String) : Config // 4
}

// 5
class Factory(
private val listComponentFactory: ListComponent.Factory,
private val detailComponentFactory: DetailComponent.Factory,
) : RootComponent.Factory {
override fun invoke(componentContext: ComponentContext): RootComponent {
return DefaultRootComponent(
listComponentFactory = listComponentFactory,
detailComponentFactory = detailComponentFactory,
componentContext = componentContext,
)
}
}
}
  1. Receive ListComponent.Factory dependency to create ListComponent and DetailComponent.Factory to create DetailComponent.
  2. Create ListComponent using the factory’s invoke method.
  3. And DetailComponent similarly.
  4. Update Config.Detail to accept postId instead of the entire post.
  5. Create a Factory class for DefaultRootComponent.

Now our components are ready for injection. 😎

Manually injecting dependencies

Create a dependencies holder class

object DependencyInjection {
val repository: PostRepository = DefaultPostRepository()

val detailComponentFactory: DetailComponent.Factory = DefaultDetailComponent.Factory(
repository = repository
)
val listComponentFactory: ListComponent.Factory = DefaultListComponent.Factory(
repository = repository,
)

val rootComponentFactory: RootComponent.Factory = DefaultRootComponent.Factory(
detailComponentFactory = detailComponentFactory,
listComponentFactory = listComponentFactory,
)
}

Android

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 1
val rootComponentFactory = DependencyInjection.rootComponentFactory

// Always create the root component outside Compose on the main thread
// 2
val rootComponent = rootComponentFactory(defaultComponentContext())

setContent {
App(rootComponent = rootComponent)
}
}
}
  1. Receive the RootComponent.Factory dependency from our DependencyInjection.
  2. Create RootComponent using the factory interface’s invoke function.

iOS

fun MainViewController() = ComposeUIViewController {
val rootComponent = remember {
val rootComponentFactory = DependencyInjection.rootComponentFactory
rootComponentFactory(DefaultComponentContext(ApplicationLifecycle()))
}
App(rootComponent = rootComponent)
}

Desktop

fun main() {
val lifecycle = LifecycleRegistry()

val rootComponentFactory = DependencyInjection.rootComponentFactory

val rootComponent = runOnUiThread {
rootComponentFactory(
componentContext = DefaultComponentContext(lifecycle),
)
}
application {
val windowState = rememberWindowState()

Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Decompose Quick Guide",
) {
LifecycleController(
lifecycleRegistry = lifecycle,
windowState = windowState,
windowInfo = LocalWindowInfo.current,
)

App(rootComponent = rootComponent)
}
}
}

Web

@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val lifecycle = LifecycleRegistry()

val rootComponentFactory = DependencyInjection.rootComponentFactory

val rootComponent = rootComponentFactory(DefaultComponentContext(lifecycle))

lifecycle.attachToDocument()

ComposeViewport(document.body!!) {
App(rootComponent = rootComponent)
}
}

Injecting dependencies with Kodein

Add the dependency

  1. First, declare the version and library of Kodein in the libs.versions.toml file.
[versions]
...
kodein-di = "7.22.0"

[libraries]
...
kodein-di = { module = "org.kodein.di:kodein-di", version.ref = "kodein-di" }

2. Update build.gradle.kts inside the composeApp folder.

...
kotlin {
...
sourceSets {
...
commonMain.dependencies {
...
implementation(libs.kodein.di)
}
}
}

3. Finally, sync Gradle changes.

Create a DI container

val kodeinDI = DI {
bindSingleton<PostRepository> { DefaultPostRepository() }

bindSingleton<DetailComponent.Factory> {
DefaultDetailComponent.Factory(
repository = instance(),
)
}

bindSingleton<ListComponent.Factory> {
DefaultListComponent.Factory(
repository = instance(),
)
}

bindSingleton<RootComponent.Factory> {
DefaultRootComponent.Factory(
detailComponentFactory = instance(),
listComponentFactory = instance(),
)
}
}
  1. Here we’re simply binding the interface to its concrete implementation.

Now we can inject dependencies. 😎

Android

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val rootComponentFactory: RootComponent.Factory by kodeinDI.instance()

// Always create the root component outside Compose on the main thread
val rootComponent = rootComponentFactory(defaultComponentContext())

setContent {
App(rootComponent = rootComponent)
}
}
}

iOS

fun MainViewController() = ComposeUIViewController {
val rootComponent = remember {
val rootComponentFactory: RootComponent.Factory by kodeinDI.instance()
rootComponentFactory(DefaultComponentContext(ApplicationLifecycle()))
}
App(rootComponent = rootComponent)
}

Desktop

fun main() {
val lifecycle = LifecycleRegistry()

val rootComponentFactory: RootComponent.Factory by kodeinDI.instance()

val rootComponent = runOnUiThread {
rootComponentFactory(
componentContext = DefaultComponentContext(lifecycle),
)
}
application {
val windowState = rememberWindowState()

Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Decompose Quick Guide",
) {
LifecycleController(
lifecycleRegistry = lifecycle,
windowState = windowState,
windowInfo = LocalWindowInfo.current,
)

App(rootComponent = rootComponent)
}
}
}

Web

@OptIn(ExperimentalComposeUiApi::class)
fun main() {
val lifecycle = LifecycleRegistry()

val rootComponentFactory: RootComponent.Factory by kodeinDI.instance()

val rootComponent = rootComponentFactory(DefaultComponentContext(lifecycle))

lifecycle.attachToDocument()

ComposeViewport(document.body!!) {
App(rootComponent = rootComponent)
}
}

Injecting dependencies with Koin

Add dependency

  1. Update the libs.versions.toml file.
[versions]
...
koin = "3.5.6"

[libraries]
...
koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" }

2. Update build.gradle.kts inside the composeApp folder.

...
kotlin {
...
sourceSets {
...
commonMain.dependencies {
...
implementation(libs.koin.core)
}
}
}

Create a DI container.

val koin by lazy { initKoin().koin } // 1

// 2
fun initKoin(appDeclaration: KoinAppDeclaration? = null) = startKoin {
appDeclaration?.invoke(this)
modules(appModule)
}

// 3
val appModule = module {
single<PostRepository> { DefaultPostRepository() }

single<DetailComponent.Factory> {
DefaultDetailComponent.Factory(
repository = get(),
)
}

single<ListComponent.Factory> {
DefaultListComponent.Factory(
repository = get(),
)
}

single<RootComponent.Factory> {
DefaultRootComponent.Factory(
detailComponentFactory = get(),
listComponentFactory = get(),
)
}
}
  1. A variable for the Koin instance.
  2. A function to start Koin. If you have platform-specific dependencies, call this method instead of using the Koin instance above. You also need an extra dependency to do that. Remember, Koin can only be started once; attempting to start it again will crash the app.
  3. Create our app module.

Now we can inject dependencies. 😎

Android

class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val rootComponentFactory: RootComponent.Factory by koin.inject()

// Always create the root component outside Compose on the main thread
val rootComponent = rootComponentFactory(defaultComponentContext())

setContent {
App(rootComponent = rootComponent)
}
}
}

iOS

fun MainViewController() = ComposeUIViewController {
val rootComponent = remember {
val rootComponentFactory: RootComponent.Factory by koin.inject()
rootComponentFactory(DefaultComponentContext(ApplicationLifecycle()))
}
App(rootComponent = rootComponent)
}

Desktop

fun main() {
val lifecycle = LifecycleRegistry()

val rootComponentFactory: RootComponent.Factory by koin.inject()

val rootComponent = runOnUiThread {
rootComponentFactory(
componentContext = DefaultComponentContext(lifecycle),
)
}
application {
val windowState = rememberWindowState()

Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Decompose Quick Guide",
) {
LifecycleController(
lifecycleRegistry = lifecycle,
windowState = windowState,
windowInfo = LocalWindowInfo.current,
)

App(rootComponent = rootComponent)
}
}
}

Web

Unfortunately, I can’t currently find a solution for Web/Wasm Koin injection with Decompose. Although there are Koin versions that support WebAssembly, they are still in alpha and may encounter runtime errors in this example. 😞

Conclusion

Congratulations! 🎉 You’ve integrated DI libraries into your KMP project with Decompose! The source code for Manual DI, Kodein, and Koin is on GitHub.

What’s next?

There’s room for improvement. Stay tuned for upcoming articles on Decompose and other enhancements.

Other Parts:

Connect

Let’s connect on LinkedIn and subscribe for updates!

Thank you!

--

--