Multiplatform Dependency Injection: Make Koin, Dagger/Hilt, and Swinject Work Together on Android, iOS, and Desktop

How to integrate shared multiplatform dependencies into multiple apps.

Jacob Ras
Better Programming

--

Koin logo with the Apple and Android logos below it.
Image by author

Different platforms typically use different frameworks for achieving dependency injection. This article shows an approach to integrating dependencies from a shared Kotlin multiplatform module, which uses Koin internally, into three apps:

  1. Desktop app, simply also using Koin;
  2. Android app, using Dagger/Hilt;
  3. iOS app, using Swinject.

This makes integrating multiplatform Kotlin code easier, because you can keep using your existing DI frameworks.

There are multiple ways to achieve this, this is just one approach. The full code is available at https://github.com/jacobras/KMP-shared-DI, while the key ideas are explained below.

Architecture

Let’s start by looking at an overview of the end result. We’ll have a shared library where we use Koin internally. In a desktop, Android and iOS app we’ll then use that shared code to inject it into the platform-specific DI frameworks and extend it.

Architecture overview showing Android, desktop and iOS apps depending on a common shared module.

Initial code

Our starting code consists of two “Printers” inside a module named [shared]. They simply return a short string and one depends on the other. There’s one called SharedPrinter, which uses the InternalPrinter. That shared one is the one we want to expose to the Android, iOS and desktop sample apps.

// [shared] src/commonMain/kotlin/com.example/Printers.kt

package com.example

// Only used in the shared module
internal class InternalPrinter {
fun print(): String {
return "Internal printer."
}
}

// This is the Printer we will later expose to Android/iOS and desktop
class SharedPrinter internal constructor(
private val internalPrinter: InternalPrinter
) {
fun print(): String {
return "Shared printer. ${internalPrinter.print()}"
}
}

// Provide the dependencies through a Koin module
val sharedModule = module {
singleOf(::InternalSharedPrinter)
singleOf(::SharedPrinter)
}

💻 App 1/3: Desktop

We’ll start with the easiest target.

In the desktop app we will just use Koin and build upon the module that’s available from the common library.

We’ll create a special DesktopPrinter inside desktop/src/jvmMain/kotlin that reuses the printer from the shared module:

// [desktop] src/jvmMain/kotlin/DesktopPrinter.kt

internal class DesktopPrinter(
private val sharedPrinter: SharedPrinter
) {
fun print(): String {
return "Desktop printer. ${sharedPrinter.print()}"
}
}

To let Koin instantiate this, we add a desktopModule where we provide the DesktopPrinter.

To make Koin actually capable of injecting the SharedPrinter, we also include the sharedModule from the shared code:

// [desktop] src/jvmMain/kotlin/Main.kt

// Desktop-only module to provide our DesktopPrinter
private val desktopModule = module {
singleOf(::DesktopPrinter)
}

fun main(args: Array<String>) {
startKoin {
// Start Koin with both modules
modules(sharedModule, desktopModule)
}

// Now the DesktopPrinter is ready to be retrieved
val desktopPrinter = get<DesktopPrinter>(DesktopPrinter::class.java)
print("Test: ${desktopPrinter.print()}")
}

Running this works as expected:

Test: Desktop printer. Shared printer. Internal printer.
Process finished with exit code 0

🤖 App 2/3: Android

That was easy because we’re simply using Koin in both the shared and desktop-specific code. On Android, however, a popular DI framework is Hilt (based on Dagger).

How can we make the Koin-provided SharedPrinter available in the Dagger graph?

This is quite simple, because we can write Android-specific code in our shared module and provide a Hilt module in there.

To do so, we add both the Koin and Hilt dependencies to the Android source set. Gradle configuration is out of scope for the general idea of this article, so see https://github.com/jacobras/KMP-shared-DI/blob/main/shared/build.gradle.kts for the complete build file.

Now, still in the [shared] module, we’re going to create a small bridge that gets the Koin dependencies and adds them to the Hilt graph. Inside shared/src/androidMain/kotlin:

// [shared] src/androidMain/kotlin/com.example/SharedModule.kt

@Module
@InstallIn(SingletonComponent::class)
class SharedModule {

// #2: Provide the SharedPrinter into the Hilt graph..
@Provides
fun printer(): SharedPrinter {
// #3: .. by actually retrieving it from the shared Koin module.
return get(SharedPrinter::class.java)
}

companion object {
fun init(context: Context) {
// #1: Instantiate Koin first.
startKoin {
androidContext(context)
modules(sharedModule)
}
}
}
}

Moving onto the Android app, we need to start up this module. We do so in our custom application class:

// [android] src/androidMain/kotlin/com.example/MyApplication.kt

@HiltAndroidApp
internal class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
SharedModule.init(this)
}
}

We’re instantiating Koin here (through this exposed SharedModule), “booting up” the graph. If you plan to share multiple modules, make sure to move this initialisation part elsewhere (for example, a shared “DI initialiser” or whatever you’d like to call it) with all the Koin modules in there, so the Koin graph initialisation happens only once. Doing it multiple times may harm performance and causes singletons to not work properly.

We can now again create a platform-specific printer to demonstrate mixing the dependencies coming from both Koin and Hilt:

// [android] src/androidMain/kotlin/com.example/AndroidPrinter.kt

@Singleton
internal class AndroidPrinter @Inject constructor(
private val sharedPrinter: SharedPrinter
) {
fun print(): String {
return "Android printer. ${sharedPrinter.print()}"
}
}

Let’s inject this printer into an activity and run it:

// [android] src/androidMain/kotlin/com.example/MainActivity.kt

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

@Inject internal lateinit var printer: AndroidPrinter

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.i(MainActivity::class.java.simpleName, "Printer says: ${printer.print()}")
}
}

This works:

com.example.shared | Printer says: Android printer. Shared printer. Internal printer.

🍎 App 3/3: iOS

Now onto the last app: the iOS one.

Here we use Swinject to do our dependency injection. We again start inside our [shared] module, by adding a small bridge inside the iOS source set at shared/src/iosMain/kotlin to allow us to obtain the SharedPrinter in our iOS app:

// [shared] src/iosMain/kotlin/SharedDi.kt

class SharedDi : KoinComponent {
init {
startKoin {
modules(sharedModule)
}
}

fun sharedPrinter(): SharedPrinter = get()
}

Swinject has a thing called “assemblies”, which act like Dagger/Hilt/Koin’s modules.

We create one in the iOS app’s code where we, just like in the Android example, grab dependencies from Koin and provide them to Swinject:

// [ios] SharedAssembly.swift

import Foundation
import Swinject
import shared

final class SharedAssembly: Assembly {
private let sharedDi = SharedDi()

func assemble(container: Swinject.Container) {
container.register(SharedPrinter.self) { _ in self.sharedDi.sharedPrinter() }
}
}

Like in [desktop] and [android], we once again add a platform-specific printer to demonstrate mixing dependencies coming from both Koin and Swinject:

// [ios] SwiftPrinter.swift

import Foundation
import shared

class SwiftPrinter {
private let sharedPrinter: SharedPrinter

init(printer: SharedPrinter) {
sharedPrinter = printer
}

func print() -> String {
return "Swift printer. " + sharedPrinter.print()
}
}

Now all that’s left is setting up a Swinject Container, adding the SharedAssembly and then using it:

// [ios] ContentView.swift

struct ContentView: View {
let container = Container()
let assembler: Assembler

init() {
// Include the shared Kotlin dependencies into the Swinject container
assembler = Assembler([SharedAssembly()], container: container)

// Include our Swift printer, which uses a dependency from the shared Kotlin module
container.register(SwiftPrinter.self) { resolver in
SwiftPrinter(
printer: resolver.resolve(SharedPrinter.self)!
)
}
}

var body: some View {
// Fetch the Swift printer from the container
let printer = container.resolve(SwiftPrinter.self)!

VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text(printer.print())
}
.padding()
}
}

Of course, this last example also works:

Swift printer. Shared printer. Internal printer.

Conclusion

As mentioned in the introduction, this is just one approach to doing this.

Read the libraries’ documentation and experiment to see what works best in your codebase(s).

Thanks to Jimmy Arts for checking if my Swift code (written as an Android engineer) makes sense.

--

--