Koin Compiler Plugin - Transformation Examples

April 21, 2026 · View on GitHub

Complete reference of all transformations performed by the plugin.

Table of Contents

  1. DSL Syntax Transformations
  2. Annotation Transformations
  3. Parameter Handling
  4. Module Injection
  5. Scope Handling

1. DSL Syntax Transformations

1.1 Reified Type Parameter Syntax: single<T>()

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()

1.2 All DSL Functions (Reified Type Syntax)

InputOutput
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.

1.3 Scope.create() (Constructor Reference)

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 supported.


2. Annotation Transformations

These transformations are handled by KoinAnnotationProcessor which fills the body of the FIR-generated .module property.

2.1 @Singleton / @Single

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())
    }
}

2.2 @Factory

Input:

@Factory
class MyService(val repo: Repository)

Generated:

buildFactory(MyService::class, null) { scope, params ->
    MyService(scope.get())
}

2.3 @KoinViewModel

Input:

@KoinViewModel
class MyViewModel(val repo: Repository) : ViewModel()

Generated:

buildViewModel(MyViewModel::class, null) { scope, params ->
    MyViewModel(scope.get())
}

2.4 @KoinWorker

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)

2.5 @Scoped

Input:

@Scoped
class SessionData(val userId: String)

Generated:

buildScoped(SessionData::class, null) { scope, params ->
    SessionData(scope.get())
}

2.6 Top-Level Functions

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 AppModule

Generated (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()
    )
}

3. Parameter Handling

These parameter transformations apply to BOTH DSL syntax (single<T>()) and annotation syntax (@Singleton).

3.1 @Named on Class

Input:

@Singleton
@Named("production")
class ProductionService : Service

Output:

buildSingle(ProductionService::class, named("production")) { scope, params ->
    ProductionService()
}.bind(Service::class)

3.2 @Named on Parameter

Input:

@Singleton
class Consumer(@Named("production") val service: Service)

Output:

buildSingle(Consumer::class, null) { scope, params ->
    Consumer(scope.get(named("production")))
}

3.3 @Qualifier with Type

Type-based qualifiers use a class reference instead of a string.

Input (on class):

@Singleton
@Qualifier(ProductionConfig::class)
class ProductionService : Service

Output:

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>()))
}

3.4 Custom @Qualifier Annotation

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 TypeQualifier

With 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").

3.5 @InjectedParam

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) }

3.4 @Property

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")
    )
}

3.5 @ScopeId

Input:

@Factory
class ProfileService(@ScopeId(name = "user_session") val session: UserSession)

Output:

buildFactory(ProfileService::class, null) { scope, params ->
    ProfileService(scope.getScope("user_session").get())
}

3.6 Scope Parameter

Input:

@Scoped
class ScopedService(val scope: Scope)

Output:

buildScoped(ScopedService::class, null) { scope, params ->
    ScopedService(scope)  // passes the scope receiver directly
}

3.7 Nullable Parameters

Input:

@Singleton
class MyService(val required: A, val optional: B? = null)

Output:

buildSingle(MyService::class, null) { scope, params ->
    MyService(scope.get(), scope.getOrNull())
}

3.6 Lazy Parameters

Input:

@Singleton
class MyService(val lazyDep: Lazy<HeavyService>)

Output:

buildSingle(MyService::class, null) { scope, params ->
    MyService(scope.inject())
}

3.7 List Parameters

Input:

@Singleton
class Aggregator(val handlers: List<Handler>)

Output:

buildSingle(Aggregator::class, null) { scope, params ->
    Aggregator(scope.getAll())
}

3.8 Default Value Handling

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

3.9 Complete Parameter Decision Table

Parameter TypeAnnotationDefault ValueGenerated Call
T (non-nullable)-Noscope.get()
T (non-nullable)-Yes(skipped - uses Kotlin default)
T? (nullable)-Noscope.getOrNull()
T? (nullable)-Yesscope.getOrNull()
T@Named("x")Noscope.get(named("x"))
T@Named("x")Yesscope.get(named("x"))
T?@Named("x")Noscope.getOrNull(named("x"))
T@Qualifier(X::class)Noscope.get(typeQualifier<X>())
T?@Qualifier(X::class)Noscope.getOrNull(typeQualifier<X>())
T@InjectedParamNoparams.get()
T?@InjectedParamNoparams.getOrNull()
String@Property("key")Noscope.getProperty("key")
String@Property("key") @PropertyValue("default")Noscope.getProperty("key", "default")
Lazy<T>-Noscope.inject()
Lazy<T>@Named("x")Noscope.inject(named("x"))
Lazy<T>@Qualifier(X::class)Noscope.inject(typeQualifier<X>())
List<T>-Noscope.getAll()
T@ScopeId(name = "x")Noscope.getScope("x").get()
T@ScopeId(X::class)Noscope.getScope("fqName").get()
T@ProvidedNoscope.get() (validation skipped)
Scope(auto-detected)Noscope (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.


4. Module Injection

4.1 startKoin()

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

4.2 koinApplication()

Input:

@KoinApplication(modules = [MyModule::class])
object MyApp

val koin = koinApplication<MyApp> {
    printLogger()
}.koin

Output:

val koin = koinApplicationWith(listOf(MyModule().module)) {
    printLogger()
}.koin

4.3 koinConfiguration()

Input:

@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>())
}.koin

4.4 KoinApplication.withConfiguration()

Input:

@KoinApplication(modules = [MyModule::class])
object MyApp

val koin = koinApplication {
    printLogger()
    withConfiguration<MyApp>()
}.koin

Output:

val koin = koinApplication {
    printLogger()
    withConfigurationWith(listOf(MyModule().module))
}.koin

Note: withConfiguration<T>() is an extension on KoinApplication that modifies the application in place.

4.5 Auto-Discovery (@Configuration)

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.

4.6 Configuration Labels

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 TestApp

Result: startKoin<TestApp>() discovers TestModule and SharedModule (both have "test" label).

4.7 module<T>() — Load Individual Modules

Input:

@Module @ComponentScan("com.app.network")
class NetworkModule

startKoin {
    module<NetworkModule>()
}

Output:

startKoin {
    modules(NetworkModule().module())
}

4.8 modules(vararg KClass) — Load Multiple Modules

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.

4.9 JSR-330 Support

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 AnnotationKoin 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

5. Scope Handling

5.1 @Scope on Class

Input:

class MyScope

@Scoped
@Scope(MyScope::class)
class SessionData

Generated (in module body):

scope<MyScope> {
    buildScoped(SessionData::class, null) { scope, params ->
        SessionData()
    }
}

5.2 Scope Archetypes

Input:

@KoinViewModel
@ViewModelScope
class MyViewModel

@Scoped
@ActivityScope
class ActivityData

@Scoped
@FragmentScope
class FragmentData

Generated: Each uses the appropriate scope builder from Koin.

5.3 ScopeDSL Functions

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) { ... }
    }
}

Appendix: Annotation Quick Reference

Definition Annotations

AnnotationGenerated DSLApplies To
@Single / @SingletonbuildSingle { }Classes, module functions, top-level functions
@FactorybuildFactory { }Classes, module functions, top-level functions
@ScopedbuildScoped { }Classes, module functions, top-level functions
@KoinViewModelbuildViewModel { }Classes, module functions, top-level functions
@KoinWorkerbuildWorker { }Classes, module functions, top-level functions

Qualifier Annotations

AnnotationEffect
@Named("x") on classString qualifier for definition → named("x")
@Named("x") on parameterget(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"))

Parameter Annotations

AnnotationEffect
@InjectedParamUse params.get()
@Property("key")Use getProperty("key")
@PropertyValue("default")Default for property

Module Annotations

AnnotationEffect
@ModuleMarks class as module container
@ComponentScanScans package for annotated classes and top-level functions
@ConfigurationTags 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

JSR-330 Annotations

AnnotationKoin Equivalent
jakarta.inject.Singleton / javax.inject.Singleton@Single
jakarta.inject.Inject / javax.inject.Inject@Factory
jakarta.inject.Named / javax.inject.Named@Named