Migrating to Koin Annotations in a multiplatform project

Simplify injecting & providing classes with simple annotations

Jacob Ras
ProAndroidDev

--

Koin is my favourite DI library, but out of the box it’s a bit verbose. With Koin Annotations, the overhead of manually writing module{} declarations is removed, making it as easy to use as Android’s Hilt library. Here’s how to use Koin Annotations in a multiplatform project.

A made-up Koin Annotations logo

Basic usage

Koin Annotations works with a KSP processor that scans for specific annotations and then creates a module{} declaration, as if you would normally write manually. If you’re not familiar with KSP, just know it’s a compiler plugin mechanism that allows for code generation.

I’ll assume starting with a project that’s already configured with Koin, but this article can also serve as a first start guide. Feel free to reply if there are any questions.

1: Configuring the build

First, declare the libraries:

// [libs.versions.toml]

[versions]
koin = "3.5.3"
koin-ksp = "1.3.1"
ksp = "1.9.22-1.0.17"

[libraries]
koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin-ksp" }
koin-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin-ksp" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }

[plugins]
ksp = { id ="com.google.devtools.ksp", version.ref = "ksp" }

Then use these dependencies:

// [build.gradle.kts]

plugins {
alias(libs.plugins.ksp)
}

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

dependenies {
add("kspCommonMainMetadata", libs.koin.compiler)
// Other compilation targets should be added here, see up in article.
}

For Android, also add implementation(libs.koin.android) in androidMain.dependencies{}.

In older Kotlin/KSP versions, it was necessary to manually add the KSP output as a source directory with kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin"). That’s no longer required as it’s automatically configured now. Same goes for the dependsOn kspCommonMainKotlinMetadata workaround you might find online. That’s why it’s recommended to use the latest dependencies.

2: Replace/add the modules to @Modules

Koin Annotations works by adding a class annotated with @Module in the same gradle module as where the the things to be injected also reside. Here’s how that looks.

You might currently have a manually written module:

// [my-module/src/commonMain/kotlin]

val myModule = module {
single<MyRepository> { MyRepositoryImpl(get(), get(), get()) }
singleOf(::AnotherClass)
factoryOf(::MoreThings)
factoryOf(::OneLinePerClass)
}

This can be replaced with:

// [my-module/src/commonMain/kotlin]

@Module
@ComponentScan
class MyModule

Now any class/function that we annotate with @Factory (for factory instances) or @Single (for singletons) inside this gradle module will be picked up by the KSP processor.

// [my-module/src/commonMain/kotlin]

@Single
class MyRepositoryImpl(
private val somethingInjected: AnotherClass
) : MyRepository

Note that it’s not needed to specify that MyRepositoryImpl should be provided when the MyRepository interface is requested to be injected somewhere. Koin binds it automatically. So with this setup, the only thing needed to add something to DI is just one annotation in most cases.

Also, if you’re used to Hilt, you might be tempted to write @Inject constructor. That’s not needed with Koin, the single annotation above the class is enough.

⚠️ @ComponentScan works by scanning from the current package into deeper packages. If you place your DI stuff in a package com.example.mymodule.di, then it won’t find code in the package com.example.mymodule, even though it’s in the same gradle module. In that case, you can instruct the processor on where it should look by changing the annotation to @ComponentScan("com.example.mymodule").

3: Use the generated modules

After a build, the generated modules are available and we can use them in our Koin init. We can check out that generated code to see if everything went well so far! It’s easy to check what’s being generated by navigating to /build/generated/ksp/[target]/kotlin. Here’s how it looks in one of my projects:

I’m glad I don’t need to write all those definitions manually anymore, as this is just one of 30+ gradle modules, each with their own Koin module!

Now let’s reference the generated module. Locate your startKoin function call and update it from this:

// Before

import org.koin.core.context.startKoin

startKoin { modules(myModule) }

to this:

// After

import org.koin.core.context.startKoin
import org.koin.ksp.generated.module // Import for the generated modules

startKoin { modules(MyModule().module) }

Note that the module is referenced as a class instantiation MyModule() and we then use .module on it to get the generated module. This is because the code Koin generates is placed in the org.koin.ksp.generated.module package.

Now run the app and see the result. That’s all there is to it!

To summarise:

  1. Add KSP + Koin Annotations processor
  2. Replace manual modules with @Module/@ComponentScan + annotate classes with @Single/@Factory
  3. Reference the generated modules from your Koin start method

Advanced usage

Including Modules

You can include Modules in each other. For example, a NetworkModule might include a separate module that provides Json (de)serialisation support. Including is done through the annotation:

@Module(includes = [JsonModule::class]
@ComponentScan
class NetworkModule

Now only including NetworkModule is enough to also get the content of JsonModule in the DI graph.

Extra configuration in Modules

If a class requires something that cannot be automatically provided, you can always manually write the code.

@Module
class MyModule {

@Single
fun provideSomething(dep: SomeDependency): Something {
return Something(dep, "extraConfig")
}
}

Extra configuration in methods

Alternatively, you can also just write a function marked with one of the annotations.

@Module
@ComponentScan
class MyModule

@Single
internal fun provideSomething(dep: SomeDependency): Something {
// Configuration here...
}

It needs to be in the same gradle module, but not in the same file. This will be picked up by the processor and result in generated code that looks something like the following:

// Generated code
public val MyModule.module: Module = module {
single() { Something (dep=get()) }
}

Platform-specific code

If you have platform-specific code, you need to run the KSP processor on that target (see Using KSP with KMP: Basic KSP API). The generated module will only be available inside source sets with the same target.

For example, inside [myModule/src/commonMain/kotlin] you can have this:

@Module
@ComponentScan
class NetworkModule

And inside the platform specific module for Android 🤖:

// [myModule/src/androidMain/kotlin]

@Single
internal fun provideAndroidHttpClient(context: Context) = HttpClient {
// Extra configuration here...
}

and for iOS 🍎:

// [myModule/src/iosMain/kotlin]

@Single
internal fun provideAppleHttpClient() = HttpClient {
// Extra configuration here...
}

If you now compile the project on Android, NetworkModule will be generated in build/generated/ksp/android/androidDebug/kotlin/org.koin.ksp.generated/NetworkModuleGen[package].kt.

And if you compile the project on iOS, NetworkModule will be generated in build/generated/ksp/IosSimulatorArm64/IosSimulatorArm64Main/kotlin/org.koin.ksp.generated/NetworkModuleGen[package].kt.

Any annotated classes inside the [common] code will be available in both platform-specific variants of the module.

If on a higher level you have an AndroidModule and IOSModule, you can simply refer the common module declaration by writing includes = [NetworkModule::class] and the right platform specific generated one will be used automatically.

For Android, if you use koin-android and include androidContext(this@Application) in your startKoin{} block, then you can inject Context anywhere you need it, as its provided by default.

ViewModel and WorkManager on Android

If you’re on Android, you might also have ViewModels and Workers you’d like to easily inject using annotations. The process is similar as above, with a difference in the annotations required. Instead of @Single and @Factory:

  • Use @KoinViewModel for ViewModels
  • Use @KoinWorker for Workers

These two are available by default in the Koin Annotations library, but note that they’re only available in the androidMain source set.

Scoping modules

Like Koin’s core library, Koin Annotations also has support for scoping with a special @Scope and @Scoped annotation, but those are .. out of scope for this article 🥁. See Scopes in Koin Annotations.

Parameters

If you need access to dependencies only accessible on runtime, you can use Koin’s parameter mechanism. The annotation is called @InjectedParam and its usage is explained here: Injected Parameters.

Further reading

--

--