Complete reference of all transformations performed by the plugin.
- DSL Syntax Transformations
- Annotation Transformations
- Parameter Handling
- Module Injection
- Scope Handling
Input (user code):
val myModule = module {
single<MyService>()
}Output (after transformation):
val myModule = module {
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.get(), scope.getOrNull())
}
}Processed by: KoinDSLTransformer.handleTypeParameterCall()
| Input | Output |
|---|---|
single<T>() |
buildSingle(T::class, null) { scope, params -> T(...) } |
factory<T>() |
buildFactory(T::class, null) { scope, params -> T(...) } |
viewModel<T>() |
buildViewModel(T::class, null) { scope, params -> T(...) } |
worker<T>() |
buildWorker(T::class, null) { scope, params -> T(...) } |
scoped<T>() |
buildScoped(T::class, null) { scope, params -> T(...) } |
Note: Constructor reference syntax single(::T) is NOT implemented. Use single<T>() instead.
Input:
val instance = koin.scope.create(::MyService)Output:
val instance = MyService(koin.scope.get(), koin.scope.get())Processed by: KoinDSLTransformer.handleScopeCreate()
This is the only place where constructor reference syntax is rewritten into codegen. Koin's own
singleOf(::T)/factoryOf(::T)/scopedOf(::T)/viewModelOf(::T) and Scope.new(::T) also use
constructor-reference syntax and are left untouched (they already resolve their own args at
runtime) — the plugin only adds compile-time dependency validation for those forms, it doesn't
transform them. See COMPILE_TIME_SAFETY.md
for validation coverage per form.
These transformations are handled by KoinAnnotationProcessor which fills the body of the FIR-generated .module property.
Input:
@Module @ComponentScan
class MyModule
@Singleton
class MyService(val repo: Repository)Generated (fills FIR-generated property body):
val MyModule.module: Module get() = module {
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.get())
}
}Input:
@Factory
class MyService(val repo: Repository)Generated:
buildFactory(MyService::class, null) { scope, params ->
MyService(scope.get())
}Input:
@KoinViewModel
class MyViewModel(val repo: Repository) : ViewModel()Generated:
buildViewModel(MyViewModel::class, null) { scope, params ->
MyViewModel(scope.get())
}Input:
@KoinWorker
class MyWorker(
context: Context,
params: WorkerParameters,
val api: ApiService
) : CoroutineWorker(context, params)Generated:
buildWorker(MyWorker::class, null) { scope, params ->
MyWorker(scope.get(), scope.get(), scope.get())
}.bind(ListenableWorker::class)Input:
@Scoped
class SessionData(val userId: String)Generated:
buildScoped(SessionData::class, null) { scope, params ->
SessionData(scope.get())
}Top-level functions can be annotated with definition annotations and discovered by @ComponentScan, just like annotated classes. Function parameters are used for dependency injection (similar to constructor parameters for classes).
Input:
package com.example
// Top-level functions with annotations
@Singleton
fun provideDatabase(): DatabaseService = PostgresDatabase()
@Factory
fun provideCache(db: DatabaseService): CacheService = RedisCache(db)
@Single
@Named("http")
fun provideHttpClient(): NetworkClient = OkHttpClient()
@Factory
fun provideServiceFacade(db: DatabaseService, cache: CacheService): ServiceFacade =
ServiceFacade(db, cache)
// Module that scans the package
@Module
@ComponentScan("com.example")
class AppModuleGenerated (fills FIR-generated property body):
val AppModule.module: Module get() = module {
// From @Singleton fun provideDatabase()
buildSingle(DatabaseService::class, null) { scope, params ->
provideDatabase()
}
// From @Factory fun provideCache()
buildFactory(CacheService::class, null) { scope, params ->
provideCache(scope.get())
}
// From @Single @Named("http") fun provideHttpClient()
buildSingle(NetworkClient::class, named("http")) { scope, params ->
provideHttpClient()
}
// From @Factory fun provideServiceFacade()
buildFactory(ServiceFacade::class, null) { scope, params ->
provideServiceFacade(scope.get(), scope.get())
}
}Key differences from class-based definitions:
- Function return type determines the binding type (not class type)
- Function parameters are injected via
scope.get()(like constructor parameters) - No dispatch receiver needed (top-level functions are called directly)
- All parameter annotations work:
@Named,@InjectedParam,@Property, nullable,Lazy<T>,List<T>
With parameter annotations:
@Factory
fun provideApiClient(
@Named("prod") baseUrl: String,
@InjectedParam userId: String,
@Property("api.timeout") timeout: Int,
cache: CacheService? // nullable = getOrNull()
): ApiClient = ApiClient(baseUrl, userId, timeout, cache)Generated:
buildFactory(ApiClient::class, null) { scope, params ->
provideApiClient(
scope.get(named("prod")),
params.get(),
scope.getProperty("api.timeout"),
scope.getOrNull()
)
}These parameter transformations apply to BOTH DSL syntax (single<T>()) and annotation syntax (@Singleton).
Input:
@Singleton
@Named("production")
class ProductionService : ServiceOutput:
buildSingle(ProductionService::class, named("production")) { scope, params ->
ProductionService()
}.bind(Service::class)Input:
@Singleton
class Consumer(@Named("production") val service: Service)Output:
buildSingle(Consumer::class, null) { scope, params ->
Consumer(scope.get(named("production")))
}Type-based qualifiers use a class reference instead of a string.
Input (on class):
@Singleton
@Qualifier(ProductionConfig::class)
class ProductionService : ServiceOutput:
buildSingle(ProductionService::class, typeQualifier<ProductionConfig>()) { scope, params ->
ProductionService()
}.bind(Service::class)Input (on parameter):
@Singleton
class Consumer(@Qualifier(ProductionConfig::class) val service: Service)Output:
buildSingle(Consumer::class, null) { scope, params ->
Consumer(scope.get(typeQualifier<ProductionConfig>()))
}An annotation class meta-annotated with @Qualifier (or @Named) becomes a reusable qualifier. When the annotation carries no value, the plugin emits a type qualifier keyed on the annotation class, so it resolves the same way as named<TheAnnotation>() at runtime.
Input:
@Qualifier
annotation class BaseUrl
@Singleton
@BaseUrl
fun provideBaseUrl(): String = "https://api.example.com"
@Singleton
class Client(@BaseUrl val url: String)Output:
buildSingle(String::class, typeQualifier<BaseUrl>()) { scope, params ->
provideBaseUrl()
}
buildSingle(Client::class, null) { scope, params ->
Client(scope.get(typeQualifier<BaseUrl>()))
}Runtime lookup — either form works:
koin.get<String>(named<BaseUrl>()) // reified TypeQualifier
koin.get<String>(typeQualifier<BaseUrl>()) // explicit TypeQualifierWith a discriminating value — custom qualifiers that carry an enum or string arg stay string-keyed (the value is what differentiates instances):
@Qualifier
annotation class Dispatcher(val kind: Dispatchers)
enum class Dispatchers { IO, DEFAULT }
@Singleton
@Dispatcher(Dispatchers.IO)
fun provideIo(): CoroutineDispatcher = Dispatchers.IO
// → buildSingle(CoroutineDispatcher::class, named("pkg.Dispatchers.IO")) { ... }Resolved at runtime with named("pkg.Dispatchers.IO").
Input:
@Factory
class MyClass(@InjectedParam val id: Int, val service: Service)Output:
buildFactory(MyClass::class, null) { scope, params ->
MyClass(params.get(), scope.get())
}Usage: koin.get<MyClass> { parametersOf(42) }
Input:
@Singleton
class Config(
@Property("server.url") val serverUrl: String,
@Property("server.port") @PropertyValue("8080") val port: String
)Output:
buildSingle(Config::class, null) { scope, params ->
Config(
scope.getProperty("server.url"),
scope.getProperty("server.port", "8080")
)
}Input:
@Factory
class ProfileService(@ScopeId(name = "user_session") val session: UserSession)Output:
buildFactory(ProfileService::class, null) { scope, params ->
ProfileService(scope.getScope("user_session").get())
}Input:
@Scoped
class ScopedService(val scope: Scope)Output:
buildScoped(ScopedService::class, null) { scope, params ->
ScopedService(scope) // passes the scope receiver directly
}Input:
@Singleton
class MyService(val required: A, val optional: B? = null)Output:
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.get(), scope.getOrNull())
}Input:
@Singleton
class MyService(val lazyDep: Lazy<HeavyService>)Output:
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.inject())
}Input:
@Singleton
class Aggregator(val handlers: List<Handler>)Output:
buildSingle(Aggregator::class, null) { scope, params ->
Aggregator(scope.getAll())
}When skipDefaultValues is enabled (default: true), parameters with Kotlin default values skip DI injection and use the default value instead. This only applies to non-nullable parameters without explicit annotations.
Input:
class ServiceWithDefault(val a: A, val name: String = "default", val count: Int = 42)
single<ServiceWithDefault>()Output (with skipDefaultValues = true):
buildSingle(ServiceWithDefault::class, null) { scope, params ->
ServiceWithDefault(scope.get()) // name and count use Kotlin defaults
}Output (with skipDefaultValues = false):
buildSingle(ServiceWithDefault::class, null) { scope, params ->
ServiceWithDefault(scope.get(), scope.get(), scope.get()) // all params injected
}Rules:
- Non-nullable + default value + no annotation = skip injection (use default)
- Nullable + default value = still inject via
getOrNull() - Annotated + default value (
@Named,@Qualifier, etc.) = still inject
| Parameter Type | Annotation | Default Value | Generated Call |
|---|---|---|---|
T (non-nullable) |
- | No | scope.get() |
T (non-nullable) |
- | Yes | (skipped - uses Kotlin default) |
T? (nullable) |
- | No | scope.getOrNull() |
T? (nullable) |
- | Yes | scope.getOrNull() |
T |
@Named("x") |
No | scope.get(named("x")) |
T |
@Named("x") |
Yes | scope.get(named("x")) |
T? |
@Named("x") |
No | scope.getOrNull(named("x")) |
T |
@Qualifier(X::class) |
No | scope.get(typeQualifier<X>()) |
T? |
@Qualifier(X::class) |
No | scope.getOrNull(typeQualifier<X>()) |
T |
@InjectedParam |
No | params.get() |
T? |
@InjectedParam |
No | params.getOrNull() |
String |
@Property("key") |
No | scope.getProperty("key") |
String |
@Property("key") @PropertyValue("default") |
No | scope.getProperty("key", "default") |
Lazy<T> |
- | No | scope.inject() |
Lazy<T> |
@Named("x") |
No | scope.inject(named("x")) |
Lazy<T> |
@Qualifier(X::class) |
No | scope.inject(typeQualifier<X>()) |
List<T> |
- | No | scope.getAll() |
T |
@ScopeId(name = "x") |
No | scope.getScope("x").get() |
T |
@ScopeId(X::class) |
No | scope.getScope("fqName").get() |
T |
@Provided |
No | scope.get() (validation skipped) |
Scope |
(auto-detected) | No | scope (receiver passed directly) |
Note: The "Default Value = Yes, skipped" behavior requires
skipDefaultValues = true(the default). When disabled, all parameters are injected regardless of default values.
Input:
@KoinApplication(modules = [MyModule::class, OtherModule::class])
object MyApp
fun main() {
startKoin<MyApp> {
printLogger()
}
}Output:
fun main() {
startKoinWith(listOf(MyModule().module, OtherModule().module)) {
printLogger()
}
}Processed by: KoinStartTransformer
Input:
@KoinApplication(modules = [MyModule::class])
object MyApp
val koin = koinApplication<MyApp> {
printLogger()
}.koinOutput:
val koin = koinApplicationWith(listOf(MyModule().module)) {
printLogger()
}.koinInput:
@KoinApplication(modules = [MyModule::class])
object MyApp
val config = koinConfiguration<MyApp>()Output:
val config = koinConfigurationWith(listOf(MyModule().module))Usage: Use with includes() to add configuration to a koinApplication:
val koin = koinApplication {
includes(koinConfiguration<MyApp>())
}.koinInput:
@KoinApplication(modules = [MyModule::class])
object MyApp
val koin = koinApplication {
printLogger()
withConfiguration<MyApp>()
}.koinOutput:
val koin = koinApplication {
printLogger()
withConfigurationWith(listOf(MyModule().module))
}.koinNote: withConfiguration<T>() is an extension on KoinApplication that modifies the application in place.
Input:
@Module @ComponentScan @Configuration
class FeatureModule
@KoinApplication // No explicit modules
object MyApp
startKoin<MyApp>()Expected Output (same compilation unit only):
startKoinWith(listOf(FeatureModule().module))Note: Cross-module discovery is limited. Use explicit modules = [...] for reliability.
Labels allow filtering which @Configuration modules are discovered:
Input:
// Module with default label
@Module @ComponentScan @Configuration
class ProdModule
// Module with specific label
@Module @ComponentScan @Configuration("test")
class TestModule
// Module with multiple labels
@Module @ComponentScan @Configuration("test", "prod")
class SharedModule
// App requesting specific labels
@KoinApplication(configurations = ["test"])
object TestAppResult: startKoin<TestApp>() discovers TestModule and SharedModule (both have "test" label).
Input:
@Module @ComponentScan("com.app.network")
class NetworkModule
startKoin {
module<NetworkModule>()
}Output:
startKoin {
modules(NetworkModule().module())
}Input:
startKoin {
modules(DataModule::class, CacheModule::class)
}Output:
startKoin {
modules(DataModule().module(), CacheModule().module())
}Note: module<T>() and modules(vararg KClass) are intercepted by KoinStartTransformer. They cannot be used inside startKoin<T> { } (which is itself intercepted) — use them inside plain startKoin { } or koinApplication { } instead.
The plugin supports JSR-330 (Jakarta/Javax) annotations as alternatives to Koin annotations:
Input:
import jakarta.inject.Singleton
import jakarta.inject.Inject
import jakarta.inject.Named
@Singleton
class MySingleton
@Inject // Equivalent to @Factory
class MyInjectable(val dep: Dependency)
class Consumer(@Named("prod") val service: Service)Output:
// @Singleton → buildSingle
buildSingle(MySingleton::class, null) { scope, params ->
MySingleton()
}
// @Inject → buildFactory
buildFactory(MyInjectable::class, null) { scope, params ->
MyInjectable(scope.get())
}Supported JSR-330 annotations:
| JSR-330 Annotation | Koin Equivalent |
|---|---|
jakarta.inject.Singleton |
@Single |
javax.inject.Singleton |
@Single |
jakarta.inject.Inject |
@Factory |
javax.inject.Inject |
@Factory |
jakarta.inject.Named |
@Named |
javax.inject.Named |
@Named |
Input:
class MyScope
@Scoped
@Scope(MyScope::class)
class SessionDataGenerated (in module body):
scope<MyScope> {
buildScoped(SessionData::class, null) { scope, params ->
SessionData()
}
}Input:
@KoinViewModel
@ViewModelScope
class MyViewModel
@Scoped
@ActivityScope
class ActivityData
@Scoped
@FragmentScope
class FragmentDataGenerated: Each uses the appropriate scope builder from Koin.
All DSL functions also work inside scope { } blocks:
Input:
val myModule = module {
scope<MyScope> {
scoped<SessionData>()
factory<SessionHandler>()
viewModel<ScopedViewModel>()
}
}Output:
val myModule = module {
scope<MyScope> {
buildScoped(SessionData::class, null) { ... }
buildFactory(SessionHandler::class, null) { ... }
buildViewModel(ScopedViewModel::class, null) { ... }
}
}| Annotation | Generated DSL | Applies To |
|---|---|---|
@Single / @Singleton |
buildSingle { } |
Classes, module functions, top-level functions |
@Factory |
buildFactory { } |
Classes, module functions, top-level functions |
@Scoped |
buildScoped { } |
Classes, module functions, top-level functions |
@KoinViewModel |
buildViewModel { } |
Classes, module functions, top-level functions |
@KoinWorker |
buildWorker { } |
Classes, module functions, top-level functions |
| Annotation | Effect |
|---|---|
@Named("x") on class |
String qualifier for definition → named("x") |
@Named("x") on parameter |
get(named("x")) |
@Qualifier(name = "x") |
String qualifier → named("x") |
@Qualifier(MyType::class) |
Type qualifier → typeQualifier<MyType>() |
Custom @Qualifier annotation (no value) |
Type qualifier → typeQualifier<TheAnnotation>() — matches runtime named<TheAnnotation>() |
Custom @Qualifier annotation (enum/string value) |
String qualifier using the value (e.g. @Dispatcher(IO) → named("pkg.NiaDispatchers.IO")) |
| Annotation | Effect |
|---|---|
@InjectedParam |
Use params.get() |
@Property("key") |
Use getProperty("key") |
@PropertyValue("default") |
Default for property |
| Annotation | Effect |
|---|---|
@Module |
Marks class as module container |
@ComponentScan |
Scans package for annotated classes and top-level functions |
@Configuration |
Tags module for auto-discovery (default label) |
@Configuration("label1", "label2") |
Tags module with specific labels for filtered discovery |
@KoinApplication(modules = [...]) |
Specifies modules to inject |
@KoinApplication(configurations = ["label"]) |
Discovers modules with matching configuration labels |
| Annotation | Koin Equivalent |
|---|---|
jakarta.inject.Singleton / javax.inject.Singleton |
@Single |
jakarta.inject.Inject / javax.inject.Inject |
@Factory |
jakarta.inject.Named / javax.inject.Named |
@Named |

