← Back to list

Koin Compiler 1.0: DSL and Annotations, Koin now Compile-Safe

Koin’s DSL and annotations now sit on a native Kotlin compiler plugin that verifies your dependency graph at build time.

Arnaud Giuliani in Koin Developers · 2026-06-03 12:01 · 98 claps · 8.9 min read
#koin #dependency-injection #kotlin #kotlin-multiplatform #android
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Koin Compiler 1.0: DSL and Annotations, Now Compile-Safe

Hello, dear Koin Community 👋

Koin Compiler 1.0 is out. Koin’s DSL and annotations now sit on a native Kotlin compiler plugin that verifies your dependency graph at build time: the missing-definition error that used to wait for a get<T>() call no longer makes it past gradlew build:

e: [Koin] Missing dependency: UserRepository (required by UserService)

No KSP, no generated files, full Kotlin Multiplatform support out of the box. It’s the biggest ergonomic shift we’ve made in Koin’s nine-year history.

Koin Logo with KotlinConf ’26 Logo

Koin Logo with KotlinConf ’26 Logo

Setup 👀

If you use a Gradle version catalog (the modern default):

[versions]
koin        = "4.2.1"
koin-plugin = "1.0.0"

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

[plugins]
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }

Or the equivalent inline in build.gradle.kts:

plugins {
    id("io.insert-koin.compiler.plugin") version "1.0.0"
}

dependencies {
    implementation("io.insert-koin:koin-core:4.2.1")
    implementation("io.insert-koin:koin-annotations:4.2.1")
}

koin-annotations is only needed if you use the annotations on your classes for the constructor, or use the annotations flow. On the DSL flow, the plugin works against koin-core alone.

The DSL flow — Safer DSL ✨

Take two classes:

class MyDatabase()
class MyRepository(val db: MyDatabase)

Wiring them up with Koin has progressively required less typing:

// 1. Manual — the wiring is explicit
module {
    single { MyDatabase() }
    single { MyRepository(get()) }
}

// 2. Reflection-free constructor binding
module {
    singleOf(::MyDatabase)
    singleOf(::MyRepository)
}
// 3. Compiler-intercepted - same DSL, new mechanism
module {
    single<MyDatabase>()
    single<MyRepository>()
}

The third form looks like nothing is happening. That’s the point: the compiler plugin sees single<MyDatabase>(), walks the constructor, and rewrites the call site into the same factory lambda you would have written by hand, except now the wiring is verified against the graph in the same pass. (More on what that verification covers in Safe Container below.)

The DSL covers the full Koin keyword set single, factory, viewModel, worker, plus scoped inside scope blocks. Constructor parameters resolve by type, and the validator understands the four kinds Koin actually supports at runtime:

class MyServiceImpl(
    val repo     : MyRepo,            // → get()
    val nullable : MyNullableRepo?,   // → getOrNull()
    val lazy     : Lazy<MyLazyRepo>,  // → inject()
    val list     : List<MyRepo>       // → getAll()
)

module {
    single<MyServiceImpl>()
}

Even if you use DSL, statically decorating your classes will require annotations. Qualifiers work through the annotations you may already know:

@Named("local")  class LocalDatabase  : Database
@Named("remote") class RemoteDatabase : Database

class SyncService(
    @Named("local")  val localDb : Database,
    @Named("remote") val remoteDb : Database
)
module {
    single<LocalDatabase>()
    single<RemoteDatabase>()
    single<SyncService>()
}

Scopes nest naturally inside scope blocks activityScope, viewModelScope, fragmentScope, plus user-defined scopes:

module {
    activityScope {
        scoped<ActivityPresenter>()
    }
    viewModelScope {
        scoped<MyUseCase>()
    }
}

For factory-style builders, third-party type, configuration logic, create(::fn) does the same auto-wiring against a function reference:

fun database(context: Context): AppDatabase =
    Room.databaseBuilder(context, ...).build()
fun topicDao(db: AppDatabase): TopicDao = db.topicDao()

module {
    single { create(::database) }
    single { create(::topicDao) }
}

The full keyword reference is available in the plugin documentation at compiler.insert-koin.io.

The Annotations flow ✨

If you prefer annotations to a DSL, the plugin processes them in the same compile pass:

@Singleton
class MyService

@Factory
class MyPresenter

@KoinViewModel
class MyViewModel : ViewModel()

@KoinWorker
class MyWorker : ListenableWorker()

Implementations are auto-bound to their declared supertype:

@Singleton
class MyServiceImpl : MyService
// → single { MyServiceImpl() } bind MyService::class

Constructor parameters follow the same resolution rules as the DSL — get / getOrNull / inject / getAll for non-null / nullable / Lazy<T> / List<T>, plus @Named for qualifiers and @InjectedParam for runtime parameters.

New in 1.0, the same definition annotations work on top-level functions — not just inside @Module classes:

@Singleton
fun database(context: Context): AppDatabase =
    Room.databaseBuilder(context, ...).build()

@Singleton
fun topicDao(db: AppDatabase): TopicDao = db.topicDao()

To collect annotated classes and functions, point a module at one or more packages:

@Module
@ComponentScan("com.example")
class AppModule

The plugin walks those packages at compile time, registers every @Singleton / @Factory / @KoinViewModel / @KoinWorker it finds, and emits the equivalent module { … } body — across Gradle module boundaries.

For larger apps, **@Configuration** lets modules auto-register into the graph without being listed explicitly:

@Module
@Configuration                          // joins the "default" configuration
class FeatureModule

@Module
@Configuration("default", "test")
class AppModule

@Module
@Configuration("test")
class TestModule

A library can ship modules tagged with a @Configuration label, and an app that asks for that label picks them up automatically. The wiring crosses Gradle boundaries through compiler hints, not source-level registration.

Starting up is a single annotated entry point:

@KoinApplication
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin<MyApplication> {
            androidContext(this@MyApplication)
        }
    }
}

Or, from Compose Multiplatform:

@KoinApplication
class KoinApp

@Composable
fun App() {
    KoinApplication(configuration = koinConfiguration<KoinApp>()) {
        MaterialTheme { /* … */ }
    }
}

startKoin<T>() is also the point where the full-graph safety check runs — covered next.

Safe Container 🌈

A missing definition is no longer something you discover in production. The plugin walks your dependency graph at compile time and verifies, for every required type, that something can provide it. Same treatment whether your definitions come from the DSL (single<T>()) or the annotations flow (@Singleton). One validator, one set of error categories.

The errors read like compiler errors, not stack traces:

e: [Koin] Missing dependency: UserRepository
   required by: UserService (parameter 'repo')
   in module: AppModule

The validator mirrors the runtime, so things that legitimately resolve to “nothing” — nullable parameters, Lazy<T>, List<T>, parameters with Kotlin defaults — don't trip the check. Qualifiers (@Named) must match, with a "did you mean" hint when an unqualified binding is nearby. Externally-provided types like Android's Context are whitelisted; your own equivalents get @Provided.

Coverage widens as the compiler sees more of your app: per-module first, then @Configuration groups, then the full graph at startKoin<T>(), then every call site (get<T>, by inject(), koinViewModel<T>). It also crosses Gradle module boundaries; a definition in a library JAR is visible to the app's validation through compiler-generated hint functions.

In practice, that surfaces as four categories of compile error:

  • missing dependency in an annotated class,
  • missing DSL dependency raised from a single<T>() / factory<T>() site
  • call-site error when a runtime get<T>() asks for something the graph doesn't provide,
  • dynamic-parameter error when an @InjectedParam definition is invoked without parametersOf(...).

The first two catch wiring drift; the third catches consumer drift (an old get<T>() left over after a definition was removed); the fourth catches the subtler runtime errors that historically had to be unit-tested.

No more runtime surprises.

Compiler options 👀

The defaults match what most projects want. If you need to tune, the options live in a koinCompiler { } block in build.gradle.kts:

koinCompiler {
    userLogs          = false  // Log component detection
    debugLogs         = false  // Log internal processing (verbose)
    compileSafety     = true   // Validate full DSL graph + call sites
    unsafeDslChecks   = true   // Validate create(::T) is the sole call in its DSL lambda
    skipDefaultValues = true   // Use Kotlin defaults for parameters that have them
}

A sixth option, strictSafety, auto-enables on modules that contain startKoin<T>() / @KoinApplication so the full-graph safety pass survives Kotlin's incremental compilation. Most projects shouldn't need to touch it.

Built on top of the Koin Container 🛟

Some compile-time DI frameworks generate a parallel container, a graph mirroring your dependencies that the runtime then walks. The plugin doesn’t need.

Koin already has a runtime container: an instance registry and a small resolution API that’s been hardened across the project’s nine-year history. The compiler plugin doesn’t replace any of it. It walks the source, verifies the wiring, and emits the same module { … } call shape you would have written by hand. It validates and wires — nothing more to generate.

The practical effects:

  • Smaller compile-time footprint. No generated container, no build/generated/ to skim, no IDE confusion about which class is the "real" one.
  • Smaller binary. The DI runtime is the DI runtime — there isn’t a per-app generated layer bolted on top.
  • KMP stops being a special case. With nothing to regenerate per target, the plugin doesn’t have to know what a Kotlin/Native ObjC export looks like or how Kotlin/Wasm lays out class hierarchies.

KMP — Less CodeGen, Less Drama 🚑

KMP was the place KSP hurt the most. Each platform needed its own processor registration, source-set wiring, and task-dependency choreography. And every time you added a target, you were updating five files to teach the build that yes, this thing needs to run on iOS too.

Less generated code is less to break per target. The plugin runs inside Kotlin compilation, on every platform Kotlin can compile to. You apply it once at the project root:

plugins {
    id("io.insert-koin.compiler.plugin") version "1.0.0"
}

And the same source compiles unchanged everywhere:

// commonMain
@Module
@ComponentScan
class CommonModule {
    @Singleton
    fun provideRepository(): Repository = RepositoryImpl()
}

Targets covered out of the box: JVM, JS, WASM, iOS, macOS, watchOS, tvOS, Linux, and Windows. Same entry point (startKoin<T>()), same configuration block for all.

Getting there meant we had to solve some genuinely hard compiler problems: multi-phase FIR compilation across source sets, deterministic synthetic-file naming, expect/actual handling, Kotlin/Native ObjC-export edge cases. None of it is your problem anymore.

Migration 🛟

Three migration paths land in 1.0: from the existing Koin DSL, from Koin Annotations on KSP, and from Dagger or Hilt. They’re different stories, but all three are supported by the same migration tooling, covered at the end.

From the existing Koin DSL

If you’re already on Koin, you don’t need to rewrite anything to adopt the plugin. Old module { … } blocks keep compiling. The migration is mostly trading verbose forms for compact ones, one definition at a time:

// Before                              // After
singleOf(::UserRepository)             single<UserRepository>()
single { ApiClient(get(), get()) }     single<ApiClient>()
viewModelOf(::UserViewModel)           viewModel<UserViewModel>()
single { database(get()) }             single { create(::database) }

For definitions that need hints (qualified dependencies, injected parameters, externally-provided types), parameter annotations stay on the constructor and the DSL stays compact:

class MyViewModel(
    private val repo: MyRepository,        // → get()
    @InjectedParam val userId: String,     // → params.get()
    @Named("api") val client: ApiClient,   // → get(named("api"))
    @Provided val ctx: PlatformContext,    // → skip safety
) : ViewModel()

viewModel<MyViewModel>()

You can migrate one module at a time. Old and new forms coexist in the same module { … }.

From Koin Annotations (KSP)

If you’re already on koin-annotations with KSP, the move is largely subtractive:

  1. Remove the KSP plugin and its ksp { } configuration block.
  2. Delete the build/generated/ksp/ paths from your IDE source roots.
  3. Apply io.insert-koin.compiler.plugin instead.
  4. Drop import org.koin.ksp.generated.* everywhere. There's nothing generated to import.
  5. Replace your KSP-generated defaultModule / .module boot with startKoin<YourKoinApp>().

The annotations themselves are unchanged. One import to know about: @KoinViewModel now lives in org.koin.core.annotation. The old location was a KSP artifact.

To put numbers behind that: when the Now in Android app was migrated end-to-end, the result was −546 lines of code across 17 modules, with per-module build configuration dropping by ~90% (from ~10 lines of KSP setup to a single plugins { } line). The whole migration took about an hour.

From Dagger or Hilt

The plugin reads JSR-330 / jakarta.inject annotations directly. @Singleton, @Inject, and @Named work without translation:

import jakarta.inject.Inject
import jakarta.inject.Singleton
import jakarta.inject.Named

@Singleton
class UserRepository @Inject constructor(
    @Named("local")  private val db  : Database,
    @Named("remote") private val api : ApiService,
)

That means you don’t have to convert every annotation in a 200-class codebase before you see anything compile. You bridge: leave existing classes annotated the way they are, point Koin at them with @ComponentScan, and let the two frameworks coexist while you migrate layer by layer.

The pattern most teams use:

  1. Start at the leaves. Repositories and data sources have few inbound dependencies; convert them first.
  2. Move up one layer at a time. ViewModels → presenters → screen-level wiring.
  3. Leave the Application class for last. Once everything resolves through Koin, swap the entry point.

Each step is a green build. You don’t have to land the whole migration in one PR.

Migration tooling — Covering Migration and other DI

The koin-migration repo ships AI skills for the three paths above (DSL → compiler-intercepted forms, KSP → plugin, Dagger/Hilt → Koin), and for other DI frameworks beyond Dagger and Hilt. They handle most of the mechanical rewrites so you spend your time on the parts that actually need judgment.

The koin-mcp server complements that by exposing the plugin to your editor through MCP, giving you in-context diagnostics during the migration.

Beaver Kotzilla’s Mascott with Koin

Beaver Kotzilla’s Mascott with Koin

Try it ✌️

plugins {
    id("io.insert-koin.compiler.plugin") version "1.0.0"
}

Requirements: Kotlin 2.3.20+ (K2 compiler) and Koin 4.2.1+.

Two reference apps are worth a clone if you want to read a working setup end-to-end:

Documentation:

We ship Koin on a six-month release cycle, with community support running on the same cadence, so the version you adopt now is supported through the next release and into the one after.

Nine years of Koin ergonomics, now with a compile-time safety net. 👍


메타데이터
post_id
06905a2b04ad
slug
koin-compiler-1-0-dsl-and-annotations-koin-now-compile-safe-06905a2b04ad
url
https://blog.insert-koin.io/koin-compiler-1-0-dsl-and-annotations-koin-now-compile-safe-06905a2b04ad
canonical_url
https://blog.insert-koin.io/koin-compiler-1-0-dsl-and-annotations-koin-now-compile-safe-06905a2b04ad
author_url
https://medium.com/@giuliani-arnaud
status
ok
fetched_at
2026-06-18 00:10:23