This document provides everything you need to understand, debug, and develop the Koin Compiler Plugin.
- Project Architecture
- Compilation Flow
- Key Files Reference
- Enabling Debug Logging
- Transformation Examples
- Running Tests
- Common Issues & Debugging
- Development Workflow
- IR Inspection Techniques
- Cross-Module Discovery (Limitations)
- Useful Commands Cheatsheet
koin-compiler-plugin/
├── koin-compiler-plugin/ # The compiler plugin (FIR + IR)
│ ├── src/org/koin/compiler/plugin/
│ │ ├── fir/ # FIR phase (declaration generation)
│ │ │ ├── KoinModuleFirGenerator.kt
│ │ │ └── KoinPluginRegistrar.kt
│ │ ├── ir/ # IR phase (code transformation)
│ │ │ ├── KoinIrExtension.kt
│ │ │ ├── KoinAnnotationProcessor.kt
│ │ │ ├── KoinDSLTransformer.kt
│ │ │ ├── KoinStartTransformer.kt
│ │ │ └── KoinHintTransformer.kt
│ │ ├── KoinConfigurationRegistry.kt
│ │ ├── KoinCommandLineProcessor.kt
│ │ └── KoinPluginComponentRegistrar.kt
│ ├── testData/ # Test input files
│ ├── test-fixtures/ # Test framework
│ └── test-gen/ # Generated test classes
│
├── koin-compiler-gradle-plugin/ # Gradle plugin for easy integration
│
└── test-apps/ # Test samples (separate Gradle project)
├── sample-app/ # KMP sample application
│ └── src/
│ ├── jvmMain/ # Main source code
│ └── jvmTest/ # Tests
└── sample-feature-module/ # Multi-module test
META-INF/services/
├── org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
│ └── KoinPluginComponentRegistrar.kt
│ └── Registers: KoinPluginRegistrar (FIR) + KoinIrExtension (IR)
│
└── org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
└── KoinCommandLineProcessor.kt
└── Plugin ID: "io.insert-koin.compiler.plugin"
Understanding the compilation flow is critical for debugging:
┌─────────────────────────────────────────────────────────────────────────────┐
│ KOTLIN COMPILATION PHASES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PHASE 1: SOURCE PARSING │ │
│ │ .kt files → Abstract Syntax Tree (AST) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PHASE 2: FIR (Frontend IR) │ │
│ │ File: KoinModuleFirGenerator.kt │ │
│ │ │ │
│ │ What happens: │ │
│ │ 1. Scans for @Module @ComponentScan classes via predicate │ │
│ │ 2. GENERATES declarations (no bodies yet): │ │
│ │ - val MyModule.module: Module (extension property) │ │
│ │ - fun org.koin.plugin.hints.configuration_default(): Nothing │ │
│ │ 3. Populates KoinConfigurationRegistry with module names │ │
│ │ │ │
│ │ Key methods: │ │
│ │ - registerPredicates() → Registers @Module lookup │ │
│ │ - getTopLevelCallableIds() → Returns what to generate │ │
│ │ - generateProperties() → Creates .module extension │ │
│ │ - generateFunctions() → Creates hint functions │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PHASE 3: IR (Intermediate Representation) │ │
│ │ File: KoinIrExtension.kt │ │
│ │ │ │
│ │ Sub-phase 0: KoinHintTransformer │ │
│ │ └── Fills bodies for FIR-generated hint functions │ │
│ │ - getConfigurationModuleClasses() → listOf("module.fqn") │ │
│ │ - hint functions → error("never call") │ │
│ │ │ │
│ │ Sub-phase 1: KoinAnnotationProcessor │ │
│ │ └── Scans @Singleton/@Factory/@KoinViewModel classes │ │
│ │ └── FILLS BODY of FIR-generated .module property: │ │
│ │ val MyModule.module = module { │ │
│ │ buildSingle(A::class, null) { A(get()) } │ │
│ │ buildFactory(B::class, null) { B(get(), get()) } │ │
│ │ } │ │
│ │ │ │
│ │ Sub-phase 2: KoinDSLTransformer │ │
│ │ └── Transforms DSL calls: │ │
│ │ single<T>() → buildSingle(T::class, null) { T(get()) } │ │
│ │ scope.create(::T) → T(scope.get(), scope.get()) │ │
│ │ │ │
│ │ Sub-phase 3: KoinStartTransformer │ │
│ │ └── Transforms app entry points: │ │
│ │ startKoin<MyApp>() → startKoinWith(modules, lambda) │ │
│ │ - Discovers @Configuration modules │ │
│ │ - Injects modules from @KoinApplication annotation │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PHASE 4: BYTECODE GENERATION │ │
│ │ IR → .class files (JVM) / .js files (JS) / native binary │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
- FIR Phase: Can only CREATE declarations (classes, functions, properties). Cannot add code bodies.
- IR Phase: Can MODIFY existing code, add bodies, transform calls. Cannot create new top-level declarations.
This is why .module property is declared in FIR but its body is filled in IR.
Location: koin-compiler-plugin/src/org/koin/compiler/plugin/KoinPluginRegistrar.kt
class KoinPluginRegistrar : FirExtensionRegistrar() {
override fun ExtensionRegistrarContext.configurePlugin() {
+::KoinModuleFirGenerator // Register our FIR extension
}
}Purpose: Entry point for FIR phase. Registers KoinModuleFirGenerator.
Location: koin-compiler-plugin/src/org/koin/compiler/plugin/fir/KoinModuleFirGenerator.kt
Purpose: Generates declarations during FIR phase.
Key Data Structures:
// Predicate to find @Module annotated classes
private val modulePredicate = LookupPredicate.create { annotated(MODULE_ANNOTATION) }
// Cached module classes (lazy evaluation)
private val moduleClasses: List<FirClassSymbol<*>> by lazy { ... }
// Cached @Configuration modules
private val configurationModules: List<FirClassSymbol<*>> by lazy { ... }Key Methods:
| Method | Purpose |
|---|---|
registerPredicates() |
Registers @Module annotation for lookup |
getTopLevelCallableIds() |
Returns CallableIds to generate (properties + functions) |
generateProperties() |
Creates val T.module: Module extension property |
generateFunctions() |
Creates hint functions in org.koin.plugin.hints |
discoverModulesFromHintsIfNeeded() |
Queries symbolProvider for hint functions in dependencies |
What Gets Generated:
For a class:
@Module @ComponentScan @Configuration
class MyModuleFIR generates:
val MyModule.module: Module(extension property, no body)fun org.koin.plugin.hints.configuration_default(contributed: MyModule): Unit(hint function for cross-module discovery)
Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinIrExtension.kt
Purpose: Orchestrates all IR transformations in correct order.
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
// Phase 0: Generate bodies for FIR-generated functions
moduleFragment.transform(KoinHintTransformer(pluginContext), null)
// Phase 1: Process annotations, fill .module property bodies
val annotationProcessor = KoinAnnotationProcessor(pluginContext, messageCollector)
annotationProcessor.collectAnnotations(moduleFragment)
annotationProcessor.generateModuleExtensions(moduleFragment)
// Phase 2: Transform single<T>() / create(::T) calls
moduleFragment.transform(KoinDSLTransformer(pluginContext, messageCollector), null)
// Phase 3: Transform startKoin<T>() calls
moduleFragment.transform(KoinStartTransformer(pluginContext, moduleFragment, messageCollector), null)
}Order matters! Each transformer depends on the previous one's output.
Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinAnnotationProcessor.kt
Purpose: Processes @Singleton, @Factory, @KoinViewModel, @Scoped annotations.
Key Data Structures:
data class ModuleClass(
val irClass: IrClass,
val scanPackages: List<String>,
val definitionFunctions: List<DefinitionFunction>,
val includedModules: List<IrClass>
)
data class DefinitionClass(
val irClass: IrClass,
val definitionType: DefinitionType, // SINGLE, FACTORY, SCOPED, VIEW_MODEL, WORKER
val bindings: List<IrClass>, // Auto-detected interfaces
val scopeClass: IrClass?, // From @Scope(MyScope::class)
val scopeArchetype: ScopeArchetype?, // @ViewModelScope, @ActivityScope, etc.
val createdAtStart: Boolean
)
enum class DefinitionType {
SINGLE, FACTORY, SCOPED, VIEW_MODEL, WORKER
}Key Methods:
| Method | Purpose |
|---|---|
collectAnnotations() |
Visits all classes, collects annotated ones |
processClass() |
Checks if class has @Module or @Singleton/etc |
generateModuleExtensions() |
Fills body of FIR-generated .module property |
buildClassDefinitionCall() |
Creates buildSingle(T::class, ...) { T(get()) } |
createDefinitionLambda() |
Creates the { scope, params -> T(get(), get()) } lambda |
generateKoinArgumentForParameter() |
Decides: get(), getOrNull(), inject(), getProperty() |
Annotation Detection Logic:
private fun getDefinitionType(declaration: IrDeclaration): DefinitionType? {
return when {
declaration.hasAnnotation(singletonFqName) -> DefinitionType.SINGLE
declaration.hasAnnotation(singleFqName) -> DefinitionType.SINGLE
declaration.hasAnnotation(factoryFqName) -> DefinitionType.FACTORY
declaration.hasAnnotation(scopedFqName) -> DefinitionType.SCOPED
declaration.hasAnnotation(koinViewModelFqName) -> DefinitionType.VIEW_MODEL
declaration.hasAnnotation(koinWorkerFqName) -> DefinitionType.WORKER
// JSR-330 support
declaration.hasAnnotation(jakartaSingletonFqName) -> DefinitionType.SINGLE
declaration.hasAnnotation(jakartaInjectFqName) -> DefinitionType.FACTORY
else -> null
}
}Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinDSLTransformer.kt
Purpose: Transforms single<T>(), factory<T>(), scope.create(::T) calls.
Key Methods:
| Method | Purpose |
|---|---|
visitCall() |
Entry point - checks if call matches our patterns |
handleTypeParameterCall() |
Handles single<T>() → buildSingle(T::class, ...) |
handleScopeCreate() |
Handles scope.create(::T) → T(scope.get(), ...) |
findTargetFunction() |
Finds the target function (buildSingle, buildFactory, etc.) |
createDefinitionLambda() |
Creates { scope, params -> T(get(), get()) } |
Target Function Mapping:
private val targetFunctionNames = mapOf(
singleName to Name.identifier("buildSingle"),
factoryName to Name.identifier("buildFactory"),
scopedName to Name.identifier("buildScoped"),
viewModelName to Name.identifier("buildViewModel"),
workerName to Name.identifier("buildWorker")
)Matching Logic in visitCall():
// Must be one of our function names
if (functionName != createName && functionName != singleName && ...) {
return transformedCall
}
// Receiver must be from Koin package
val receiverPackage = receiverClassifier.packageFqName?.asString()
if (!receiverPackage.startsWith("org.koin.core") &&
!receiverPackage.startsWith("org.koin.dsl")) {
return transformedCall
}
// For type parameter syntax: single<T>()
if (transformedCall.valueArgumentsCount == 0 &&
transformedCall.typeArgumentsCount >= 1 &&
extensionReceiver != null) {
return handleTypeParameterCall(...)
}Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinStartTransformer.kt
Purpose: Transforms startKoin<MyApp>() to inject modules.
Key Methods:
| Method | Purpose |
|---|---|
visitCall() |
Detects startKoin/koinApplication calls |
extractModulesFromKoinApplicationAnnotation() |
Gets modules from @KoinApplication |
extractExplicitModules() |
Gets modules from modules = [...] parameter |
discoverLocalConfigurationModules() |
Scans current compilation for @Configuration |
discoverModulesFromHints() |
Tries to find modules from dependencies |
buildModuleGetCall() |
Creates MyModule().module expression |
Discovery Strategies:
private fun discoverModulesFromHints(): List<IrClass> {
// Strategy 1: Local hints from moduleFragment
for (file in moduleFragment.files) {
if (file.packageFqName == hintsPackage) { ... }
}
// Strategy 2: Query via IR (limited - can't enumerate)
// Note: IR cannot enumerate package contents from dependencies
// Strategy 3: In-memory registry (same compilation only)
val registryClassNames = KoinConfigurationRegistry.getAllModuleClassNames()
for (moduleClassName in registryClassNames) {
val classId = ClassId.topLevel(FqName(moduleClassName))
val moduleClass = context.referenceClass(classId)?.owner
// ...
}
}Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinHintTransformer.kt
Purpose: Fills bodies for FIR-generated hint functions.
override fun visitSimpleFunction(declaration: IrSimpleFunction): IrSimpleFunction {
if (fqName?.parent() == hintsPackage && declaration.body == null) {
if (declaration.name == registryFunctionName) {
// Generate: return listOf("module1.fqn", "module2.fqn")
declaration.body = generateRegistryFunctionBody(declaration)
} else {
// Generate: throw error("Hint function - never call")
declaration.body = generateHintFunctionBody(declaration)
}
}
return declaration
}Location: koin-compiler-plugin/src/org/koin/compiler/plugin/KoinConfigurationRegistry.kt
Purpose: Cross-phase communication between FIR and IR.
object KoinConfigurationRegistry {
private val localModuleClassNames = mutableSetOf<String>()
private val jarModuleClassNames = mutableSetOf<String>()
fun registerLocalModule(moduleClassName: String) { ... }
fun registerJarModule(moduleClassName: String) { ... }
fun getLocalModuleClassNames(): Set<String> { ... }
fun getAllModuleClassNames(): Set<String> { ... }
fun clear() { ... }
}CRITICAL LIMITATION: This registry is per-JVM instance. Each Gradle compilation task runs in a separate context, so registry is NOT shared across compilation tasks!
Enable logging via the koinCompiler extension in your build.gradle.kts:
koinCompiler {
userLogs = true // Component detection logs (what's being processed)
debugLogs = true // Internal processing logs (verbose)
unsafeDslChecks = true // Validates create() is the only instruction in lambda (default: true)
skipDefaultValues = true // Skip injection for parameters with default values (default: true)
}View logs during compilation:
cd test-apps
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "\[Koin"Log prefixes:
| Prefix | Phase | Level |
|---|---|---|
[Koin] |
IR | User |
[Koin-Debug] |
IR | Debug |
[Koin-FIR] |
FIR | User |
[Koin-Debug-FIR] |
FIR | Debug |
With userLogs = true:
w: [Koin] @Module/@ComponentScan on class MyModule
w: [Koin] Scanning packages: examples.annotations
w: [Koin] @Singleton on class MyService
w: [Koin] @Named("production")
w: [Koin] Intercepting single<MyClass>() on Module
w: [Koin] Skipping injection for parameter 'timeout' - using default value
w: [Koin] Intercepting startKoin<MyApp>()
w: [Koin] -> Injecting modules: examples.annotations.MyModule
w: [Koin-FIR] Found 1 @Configuration modules
With debugLogs = true (additional verbose output):
w: [Koin-Debug-FIR] Looking for @Configuration modules among 2 @Module classes
w: [Koin-Debug-FIR] -> examples.annotations.MyModule: @Configuration=true
w: [Koin-Debug-FIR] Adding 1 hint functions to getTopLevelCallableIds()
w: [Koin-Debug] visitCall: org.koin.plugin.module.dsl.single | args=0 | typeArgs=1
w: [Koin-Debug] Creating definition lambda for MyService
Input (user code):
val myModule = module {
single<MyService>()
}Matched by: KoinDSLTransformer.handleTypeParameterCall()
Output (after transformation):
val myModule = module {
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.get(), scope.getOrNull())
}
}Input:
koin.scope.create(::MyService)Matched by: KoinDSLTransformer.handleScopeCreate()
Output:
MyService(koin.scope.get(), koin.scope.get())Input:
@Module @ComponentScan
class MyModule
@Singleton
class MyService(val repo: Repository, val logger: Logger?)Processed by: KoinAnnotationProcessor
Generated (fills FIR-generated property body):
val MyModule.module: Module get() = module {
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.get(), scope.getOrNull())
}
}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")))
}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)Output:
buildSingle(Config::class, null) { scope, params ->
Config(scope.getProperty("server.url"))
}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())
}Input:
@KoinApplication(modules = [MyModule::class])
object MyApp
fun main() {
startKoin<MyApp> {
printLogger()
}
}Output:
fun main() {
startKoinWith(listOf(MyModule().module)) {
printLogger()
}
}./gradlew :koin-compiler-plugin:testTests use Kotlin's internal test framework with .kt files in testData/.
cd test-apps
./gradlew :sample-app:jvmTestcd test-apps
./gradlew :sample-app:jvmTest --tests "examples.annotations.AnnotationsConfigTest"
./gradlew :sample-app:jvmTest --tests "examples.DSLTest"cd test-apps
./gradlew :sample-app:jvmRuncd test-apps
./gradlew :sample-app:jvmTest --info 2>&1 | grep -E "(PASSED|FAILED|Koin-Plugin)"./gradlew :koin-compiler-plugin:test -Pupdate.testdata=trueSymptom: startKoin<MyApp>() logs "No modules to inject"
Debug Steps:
-
Check
@KoinApplicationannotation:@KoinApplication(modules = [MyModule::class]) // Explicit is best object MyApp
-
Check compilation logs for hint generation:
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "Configuration"
-
Verify modules are in same compilation unit (cross-module discovery is limited)
Symptom: MyModule().module doesn't compile
Debug Steps:
-
Verify class has BOTH annotations:
@Module // Required @ComponentScan // Required class MyModule
-
Check compilation logs:
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "getTopLevelCallableIds"
-
For KMP projects, if you encounter issues, try disabling incremental compilation:
# gradle.properties kotlin.incremental.multiplatform=false
Symptom: single<T>() not transformed, runtime error
Debug Steps:
-
Add log in
visitCall():log("visitCall: ${callee.fqNameWhenAvailable} receiver=${receiver.type}") -
Check receiver type is from Koin:
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "visitCall"
-
Verify import is correct:
import org.koin.dsl.module import org.koin.core.module.dsl.single // NOT org.koin.dsl.single
Symptom: Missing qualifiers, nullable not handled
Debug Steps:
-
Check
generateKoinArgumentForParameter()in logs:log("Generating arg for ${param.name}: type=${param.type}, nullable=${param.type.isMarkedNullable()}") -
Verify annotation FQNames:
log("Annotations on ${param.name}: ${param.annotations.map { it.type.classFqName }}")
Symptom: Compilation fails on iOS/macOS with file mismatch
Cause: FIR generators running on wrong source sets
Fix: Already applied - hint generation only when configurationModules.isNotEmpty()
Symptom: Hint functions disappear, cross-module discovery fails
Cause: Multiple FIR sessions (commonMain, jvmMain) writing to same hints package
Fix: Check in getTopLevelCallableIds():
if (configurationModules.isNotEmpty()) {
// Generate hints
} else if (moduleClasses.isEmpty()) {
// Skip - empty compilation
} else {
// Has @Module but no @Configuration - just trigger
}Symptom: Runtime error like java.lang.NoSuchMethodError: No static method module(PlatformComponentModule)
Cause: Multiple FIR phases (commonMain, androidMain) generating to same synthetic file name, later phases overwriting earlier ones.
Debug Steps:
-
Check FIR logs for duplicate file generation:
./gradlew :composeApp:assembleDebug 2>&1 | grep "GENERATED"
Look for multiple classes generating to
__GENERATED__CALLABLES__Kt.kt -
Check which classes are in the generated file:
javap -p build/tmp/kotlin-classes/debug/com/example/__GENERATED__CALLABLES__Kt.class
-
Verify unique file names are used:
ls build/tmp/kotlin-classes/debug/com/example/__GENERATED__*Should see:
__GENERATED__AppModule__KtKt.class,__GENERATED__PlatformComponentModule__KtKt.class, etc.
Fix: Already implemented - uses unique file names per class: __GENERATED__${className}__Kt.kt
Symptom: Compilation error or "no body for FIR-generated function" for expect classes
Cause: FIR generator not filtering out expect classes
Debug Steps:
-
Check FIR logs for expect class handling:
./gradlew :composeApp:assembleDebug 2>&1 | grep -E "(expect|Skipping)"
Should see:
Skipping expect class: com/example/PlatformComponentModule -
Verify
rawStatus.isExpectcheck inKoinModuleFirGenerator.kt
Fix: Already implemented - expect classes are filtered:
.filter { classSymbol -> !classSymbol.rawStatus.isExpect }Symptom: K/Native compilation fails with "The number of source files (X) does not match the number of IrFiles (Y)"
Cause: FIR generating synthetic files for metadata classes on K/Native targets
Debug Steps:
-
Check FIR logs for K/Native skipping:
./gradlew :composeApp:compileKotlinIosArm64 2>&1 | grep "K/Native"
Should see:
Skipping module() for ... (from metadata, K/Native) -
Verify platform detection:
val isNativeTarget = session.moduleData.platform.isNative()
Fix: Already implemented - synthetic file generation skipped on K/Native
Symptom: IR phase can't find module() function for a module in @Module(includes = [...])
Cause: Module is from different source set (e.g., androidMain including commonMain module)
Debug Steps:
-
Check IR logs for module resolution:
./gradlew :composeApp:assembleDebug 2>&1 | grep "Found module()"
-
Look for cross-source-set lookup:
Found module() in moduleFragment for DataModule # Same source set Found module() via context for PlatformComponentModule # Cross source set
Fix: Already implemented - uses findModuleFunctionViaContext() fallback for cross-compilation lookup
Symptom: Compilation fails with error about synthetic accessors for Kotlin object
Cause: Code calling constructor on a Kotlin object (singleton) instead of using the instance
Debug Steps:
-
Check if the module is defined as
object:@Module object MyModule // Object - should use MyModule.INSTANCE -
Verify
isObjectcheck in IR:./gradlew :composeApp:assembleDebug 2>&1 | grep "object"
Fix: Already implemented - uses irGetObject() for object modules:
val instanceExpression = if (includedModuleClass.isObject) {
builder.irGetObject(includedModuleClass.symbol)
} else {
builder.irCallConstructor(constructor.symbol, emptyList())
}Symptom: After updating the plugin, IntelliJ shows class symbols (like A, B, C, etc.) as red/unresolved in test-apps or other projects using the plugin.
Cause: IntelliJ has cached the old plugin version and hasn't picked up the newly installed one from Maven Local.
Fix Steps (in order of least to most aggressive):
-
Reinstall plugin to Maven Local:
./install.sh
-
Refresh Gradle in IntelliJ:
- Open the Gradle tool window (View → Tool Windows → Gradle)
- Click the refresh button (🔄 icon)
-
Invalidate IntelliJ caches:
- Go to
File→Invalidate Caches... - Check "Clear file system cache and Local History"
- Click
Invalidate and Restart
- Go to
-
Clean and reimport project (last resort):
cd test-apps rm -rf .gradle build */build .idea/*.xml
Then reopen the project in IntelliJ and let it reimport.
Prevention: When actively developing the plugin, consider keeping test-apps in a separate IntelliJ window and refreshing Gradle after each ./install.sh.
Symptom: Compile error at an @KoinViewModel-annotated class:
[Koin] @KoinViewModel definition 'com.app.MyViewModel' cannot be generated:
'buildViewModel' is not on classpath. Add dependency: io.insert-koin:koin-core-viewmodel
Same shape for @KoinWorker → io.insert-koin:koin-android-workmanager.
Cause: @KoinViewModel / @KoinWorker annotations live in koin-annotations (always available via koin-core), but the runtime DSL that backs them (Module.buildViewModel / Module.buildWorker) ships in a separate artifact. If you only have koin-core + koin-annotations, the plugin can't generate a working definition for those annotations.
Before the check existed (pre-RC2.3), the plugin silently skipped the definition; you got NoDefinitionFoundException at runtime. RC2.3+ fails loudly with the fix instruction.
Fix:
// build.gradle.kts
dependencies {
implementation("io.insert-koin:koin-core-viewmodel:4.2.1") // for @KoinViewModel
implementation("io.insert-koin:koin-android-workmanager:4.2.1") // for @KoinWorker (Android)
}The error fires once per annotation type per compilation — if you see it for @KoinViewModel, all ViewModel definitions in that compilation are blocked until the artifact is added.
# 1. Edit compiler-plugin code
# e.g., koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinDSLTransformer.kt
# 2. Publish to Maven Local
./install.sh
# 3. Test changes
cd test-apps
./gradlew clean :sample-app:jvmTest# From project root
./install.sh && cd test-apps && ./gradlew clean :sample-app:jvmTestcd test-apps
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "\[Koin-Plugin\]"-
Identify the phase: FIR (new declarations) or IR (transform code)?
-
For IR transformations:
- Add to appropriate transformer (
KoinDSLTransformer,KoinAnnotationProcessor) - Pattern match in
visitCall()orvisitClass() - Create the transformed IR using
DeclarationIrBuilder
- Add to appropriate transformer (
-
For FIR declarations:
- Add to
KoinModuleFirGenerator - Return new CallableId in
getTopLevelCallableIds() - Generate in
generateProperties()orgenerateFunctions()
- Add to
-
Add target function (if needed):
- Add to
plugin-support/src/commonMain/kotlin/org/koin/plugin/module/dsl/ - Create stub function (what user writes) and target function (what plugin transforms to)
- Note:
plugin-supportis included in Koin (koin-annotations), coordinate with Koin releases
- Add to
The recommended approach is to enable debugLogs in your Gradle configuration:
koinCompiler {
debugLogs = true
}This provides detailed output about IR transformations without modifying plugin source code.
# After compilation
cd test-apps/sample-app/build/classes/kotlin/jvm/main
# Decompile a class
javap -c -p examples/annotations/MyModule.class
# View all hints
javap -c org/koin/compiler/hints/*.class- Open
compiler-pluginin IntelliJ - Set breakpoint in any transformer
- Run test with debugger:
./gradlew :koin-compiler-plugin:test --debug-jvm
- Connect IntelliJ debugger to port 5005
When compiling Module B that depends on Module A (already compiled JAR):
- FIR can query
symbolProvider.getTopLevelFunctionSymbols()across JARs - IR CANNOT enumerate package contents from dependencies
FIR Phase (KoinModuleFirGenerator.kt):
fun discoverModulesFromHintsIfNeeded() {
val callableNames = session.symbolProvider.symbolNamesProvider
.getTopLevelCallableNamesInPackage(HINTS_PACKAGE)
for (name in callableNames) {
val funcSymbols = session.symbolProvider
.getTopLevelFunctionSymbols(HINTS_PACKAGE, name)
// Extract module class from parameter type
}
}IR Phase (KoinStartTransformer.kt):
fun discoverModulesFromHints(): List<IrClass> {
// Strategy 1: Local hints (same compilation)
// Strategy 2: IR query (limited)
// Strategy 3: Registry (same JVM only)
}- Registry is per-JVM: Each Gradle task runs separately
- IR can't enumerate: Must know exact function names
- Empty compilations overwrite: Fixed by checking
configurationModules.isNotEmpty()
Use explicit module specification:
@KoinApplication(modules = [ModuleA::class, ModuleB::class])
object MyAppThe plugin uses metadata registration for cross-module visibility:
pluginContext.metadataDeclarationRegistrar.registerFunctionAsMetadataVisible(function)This makes FIR-generated functions visible in downstream IR phases.
# Build plugin and publish to Maven Local
./install.sh
# Build plugin only (no publish)
./gradlew :koin-compiler-plugin:build
# Clean everything
./gradlew clean
cd test-apps && ./gradlew clean# Plugin unit tests
./gradlew :koin-compiler-plugin:test
# Sample app tests
cd test-apps && ./gradlew :sample-app:jvmTest
# Specific test
./gradlew :sample-app:jvmTest --tests "examples.annotations.AnnotationsConfigTest"
# Run sample
./gradlew :sample-app:jvmRun# View plugin logs during compilation
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "\[Koin-Plugin\]"
# View FIR logs (enable debugLogs in koinCompiler config)
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "\[Koin"
# Decompile generated bytecode
javap -c -p build/classes/kotlin/jvm/main/examples/annotations/MyModule.class
# List generated hints
ls -la build/classes/kotlin/jvm/main/org/koin/compiler/hints/# Quick rebuild + test
./install.sh && cd test-apps && ./gradlew :sample-app:jvmTest
# Compile only (faster)
cd test-apps && ./gradlew :sample-app:compileKotlinJvm
# Force clean rebuild
cd test-apps && ./gradlew clean :sample-app:compileKotlinJvm --no-build-cache# iOS compilation
cd test-apps && ./gradlew :sample-app:compileKotlinIosSimulatorArm64
# All native targets
./gradlew :sample-app:compileKotlinNative
# JS compilation
./gradlew :sample-app:compileKotlinJs| File | Phase | Purpose |
|---|---|---|
KoinPluginComponentRegistrar.kt |
- | Plugin entry point |
KoinPluginRegistrar.kt |
FIR | Registers FIR extensions |
KoinModuleFirGenerator.kt |
FIR | Generates .module property + hints |
KoinConfigurationRegistry.kt |
FIR→IR | Cross-phase communication |
KoinIrExtension.kt |
IR | Orchestrates IR transformers |
KoinHintTransformer.kt |
IR-0 | Fills hint function bodies |
KoinAnnotationProcessor.kt |
IR-1 | Processes @Singleton etc |
KoinDSLTransformer.kt |
IR-2 | Transforms single() |
KoinStartTransformer.kt |
IR-3 | Transforms startKoin() |
Last updated: 2026-02-02