Compile-Time Dependency Validation
July 29, 2026 · View on GitHub
Detect missing dependencies at compile time instead of runtime crashes.
The Problem
Without compile-time safety, a missing dependency only surfaces at runtime:
@Module @ComponentScan
class AppModule
@Singleton
class MyService(val repo: Repository)
// Repository is never declared → compiles fine, crashes at runtime:
// "No definition found for class 'Repository'"
What Gets Validated
| Scenario | Result |
|---|---|
| Non-nullable param, no definition | ERROR |
Nullable param (T?), no definition | OK — uses getOrNull() |
| Param with default value, no definition | OK — uses Kotlin default (when skipDefaultValues=true) |
@InjectedParam, no definition | OK — provided at runtime via parametersOf() |
@Property("key") param | OK — property injection, not DI validation |
List<T> param | OK — getAll() returns empty list if none |
Lazy<T>, no definition for T | ERROR — unwraps to validate inner type |
@Named("x") param, no matching qualifier | ERROR — with hint if unqualified binding exists |
| Scoped dependency from wrong scope | ERROR |
Default value param with @Named qualifier | ERROR — qualifier forces injection |
@Provided type, no definition | OK — externally provided at runtime |
Android framework type (e.g. Context) | OK — hardcoded whitelist |
Validation Scopes
As of 1.1.0, there is exactly one validation pass: the full graph at a Koin entry point (A3),
plus call-site validation (A4). Earlier versions ran validation per-module too (A1: local +
includes, A2: mutually-visible @Configuration siblings) — both were removed in 1.1.0. A
module validated in isolation cannot know how it will be wired into a larger app; this stopped
being theoretical when a real cross-module false positive was measured (a dependency genuinely
provided by a peer module, with no Gradle edge between the two — see
docs/COMPILE_SAFETY_A3_PLAN.md's superseded-banner for the full account). @Module(includes=…)
and @Configuration labels are now purely module-discovery mechanisms — they decide which
modules get unioned into the one graph A3 validates, not a separate visibility tier with its own
validation pass.
Trade-off: a compilation unit with no Koin entry point of its own (a leaf library, most feature
modules) gets no compile-time safety diagnostics at all — not even the isolated, false-positive-
prone checking A1/A2 used to do. This is disclosed via an INFO-severity message, never silent. The
whole graph is verified once, correctly, wherever startKoin/koinApplication/@KoinApplication
actually assembles it.
A3: startKoin Entry Point (full graph)
When startKoin<T>() is used with @KoinApplication, the full assembled graph is validated.
@KoinApplication(modules = [CoreModule::class, ServiceModule::class])
object MyApp
startKoin<MyApp> { }
// Validates: ALL definitions from CoreModule + ServiceModule combined
A4: Call-Site Validation
Validates resolution call sites: get<T>(), inject<T>(), koinViewModel<T>(), etc. These are calls outside of module definitions that resolve a type from the DI container at runtime.
// In an Activity or Fragment:
val service: MyService by inject()
// Validates: MyService is available in the assembled graph
Call sites are collected during Phase 2 (KoinDSLTransformer.collectCallSiteIfResolutionFunction) and validated in Phase 3.5 against the assembled graph, DSL definitions, and dependency hints.
When a call site cannot be resolved locally (e.g., in a feature module without the full graph), it generates a callsite(required: T) hint function for deferred validation. The app module discovers and validates these hints in Phase 3.6.
Phase 3.1: DSL-Only A3
When startKoin { } is present but no startKoin<T>() or @KoinApplication is used, Phase 3.1 performs A3-style validation on DSL definitions only. It validates constructor parameters of local DSL definitions (single<T>(), factory<T>(), etc.) against all known providers (local DSL + dependency DSL hints + annotation definitions). This catches missing definitions like commenting out single<Repository>() that a ViewModel needs.
Only runs in the entry-point module (the one that calls startKoin { }) to avoid false positives in leaf modules that don't have the full graph visible.
Phase 3.5: Pending Call-Site Validation with Deferred Hint Generation
After all definitions are collected and startKoin is processed, pending call sites are validated against the combined set of assembled graph types, local DSL definitions, and cross-module DSL hints from dependencies.
Cross-module DSL hints are merged into the call-site known-types set unconditionally — including when A3's full graph is already populated. The assembled graph from A3 only covers @Module classes' definitions; it doesn't reach DSL module { … } properties loaded via modules(dslModule) from upstream source sets. See Mixing @KoinApplication with DSL modules for the mixed-scenario case this closes.
Unresolved call sites in modules without a full graph generate callsite(required: T) hint functions in org.koin.plugin.hints. These hints are synthetic IR functions that encode the required type as a parameter, allowing downstream modules to discover and validate them.
Phase 3.6: Cross-Module Call-Site Hint Validation
The app module (or any module with all definitions visible) discovers call-site hints from dependency modules via context.referenceFunctions(callsite) and validates each required type against the full set of known definitions. This completes the deferred validation started in Phase 3.5.
Error Messages
Errors report the missing type, which definition needs it, and in which module:
[Koin] Missing dependency: Repository
required by: Service (parameter 'repo')
in module: ServiceModule
When a binding exists with a different qualifier, a hint is shown:
[Koin] Missing dependency: NetworkClient (qualifier: @Named("http"))
required by: ApiService (parameter 'client')
in module: AppModule
Hint: Found NetworkClient without qualifier — did you mean to add @Named("http")?
For A3 validation, the application name is used:
[Koin] Missing dependency: MissingDep
required by: Service (parameter 'missing')
in module: MyApp (startKoin)
External Types: @Provided and Whitelist
Some types are provided by the platform or framework at runtime (e.g., Android's Context, SavedStateHandle) and are never declared as Koin definitions. Without special handling, these would trigger false "missing dependency" errors.
Two mechanisms prevent this:
@Provided Annotation
Mark a type or parameter as externally available at runtime. The safety checker skips it during validation.
Can be used on a class (all usages of that type are skipped) or on a parameter (only that specific parameter is skipped):
// Class-level: all usages of SavedStateHandle skip validation
@Provided
class SavedStateHandle
// Parameter-level: only this specific parameter is skipped
@Singleton
class MyService(@Provided val ctx: PlatformContext)
Class-level @Provided is collected during Phase 1 annotation scanning and stored in ProvidedTypeRegistry.
Parameter-level @Provided is detected in ParameterAnalyzer and marks the Requirement as not requiring validation.
Hardcoded Framework Whitelist
Common Android framework types are always skipped, without requiring @Provided:
| Type | Source |
|---|---|
android.content.Context | Android core |
android.app.Activity | Android core |
android.app.Application | Android core |
androidx.fragment.app.Fragment | AndroidX |
androidx.lifecycle.SavedStateHandle | AndroidX |
androidx.work.WorkerParameters | AndroidX |
The whitelist is defined in BindingRegistry.WHITELISTED_TYPES.
Both @Provided and the whitelist are checked before reporting a missing dependency. If either matches, the type is considered satisfied.
@Provided is also the required declaration for dependencies that arrive through runtime module
loading — see Runtime Module Loading (loadKoinModules).
Special Parameter Handling
Scope Parameter Injection
Parameters of type org.koin.core.scope.Scope are injected with the scope receiver itself (not resolved via scope.get<Scope>()). Validation is automatically skipped.
@Scoped
class ScopedService(val scope: Scope) {
fun dynamicLookup() = scope.get<SomeDep>() // Not validated at compile time
}
// Generates: ScopedService(scope) — passes the scope receiver directly
@ScopeId — Named Scope Resolution
Parameters annotated with @ScopeId are resolved from a named Koin scope. Validation is skipped since the scope is resolved at runtime.
Supports two forms:
@ScopeId(name = "my_scope")— string-based scope ID@ScopeId(MyScope::class)— type-based scope ID (uses FQ class name)
@Factory
class ProfileService(@ScopeId(name = "user_session") val session: UserSession)
// Generates: ProfileService(scope.getScope("user_session").get<UserSession>())
@Property/@PropertyValue Validation
The compiler warns when @Property("key") has no matching @PropertyValue("key") default in the same compilation unit. This is a warning (not error) since properties can be set at runtime via properties().
@PropertyValue("api.timeout")
val defaultTimeout = 30
@Factory
class ApiClient(@Property("api.timeout") val timeout: Int)
// OK — @PropertyValue("api.timeout") provides a default
@Factory
class Other(@Property("missing.key") val value: String)
// WARNING — no @PropertyValue("missing.key") found
Module Load Order and Overrides
Koin is last-wins at runtime: when two modules define the same type, the one loaded last takes precedence. The compiler plugin assembles the module list at the @KoinApplication root in this order:
- Auto-discovered
@Configurationmodules (this compilation + dependency JARs) — load first - Explicit
@KoinApplication(modules = [A, B, C])— load last, in declaration order
The rationale: apps customise libraries, not the other way round. So the app's explicit list wins over dependency-provided defaults.
// Dependency JAR — default implementation
@Module @Configuration
class CoreModule {
@Singleton fun feature(): Feature = DefaultFeature()
}
// App — custom override
@Module
class AppModule {
@Singleton fun feature(): Feature = AppFeature()
}
@KoinApplication(modules = [AppModule::class])
class MyApp
// Load order: CoreModule (DefaultFeature) → AppModule (AppFeature wins)
Within the explicit list, declaration order is preserved:
@KoinApplication(modules = [A::class, B::class, C::class])
// Load order: (@Configuration deps) → A → A.includes → B → B.includes → C → C.includes
// Winner among A/B/C: C (declared last)
If a module re-appears in both the explicit list and is also discovered via @Configuration, it is loaded once — at its explicit position — so the user's declaration order always controls override precedence.
Escape hatch for fine-grained ordering: list all participating modules explicitly in @KoinApplication(modules = [...]) in the desired order. This bypasses classpath-dependent discovery order for @Configuration modules.
Runtime Module Loading (loadKoinModules)
loadKoinModules(...) loads modules after startKoin — feature-scoped loading, dynamic
delivery, plugin architectures, test setup. Graphs assembled this way cannot be verified at compile
time, and the reason is fundamental rather than a gap in the implementation:
- the call happens at an arbitrary runtime moment, so the plugin cannot prove a module is loaded
before a given
get<T>()resolves against it; - the module may never be loaded at all on some code paths;
unloadKoinModules(...)can remove it again afterwards.
The plugin therefore does not try to prove such a graph correct — it would have to invent knowledge it does not have. Instead:
Dependencies that arrive via runtime module loading must be declared
@Provided.
// Arrives at runtime, not at startKoin
@Provided
class Repository
val featureModule = module { single<Repository>() } // loaded later
val appModule = module { single<Service>() } // Service(val repo: Repository)
fun main() {
startKoin { modules(appModule) }
loadKoinModules(featureModule) // runtime load
}
Without @Provided, Repository is reported missing — correctly, because at compile time it is
genuinely absent from the assembled graph:
| Diagnostic | Where |
|---|---|
KOIN-D001 | Service's constructor dependency on Repository |
KOIN-D002 / KOIN-D003 | any get<Repository>() / inject<Repository>() call site |
@Provided is the declaration of intent — "this type arrives from outside the graph the compiler
can see" — the same mechanism used for platform types like Context (see
@Provided above). Validation steps back, responsibility moves to you, and
the graph is checked at runtime by Koin's checkModules().
Scope it as narrowly as possible. Prefer parameter-level @Provided on the specific injection
point; class-level disables validation for every usage of that type:
class Service(@Provided val repo: Repository) // only this parameter is skipped
Known rough edge. The runtime-loaded module itself is still reported as unreachable:
[KOIN-W001] Module 'featureModule' is not loaded at startKoin — 1 definitions unreachable: Repository
Add it to modules() or includes() to make these definitions available
That advice does not apply to this pattern — adding the module to modules() would defeat the point
of loading it on demand. The warning is harmless unless you build with allWarningsAsErrors.
Mixing @KoinApplication with DSL modules
@KoinApplication(modules = […]) only accepts KClass references to @Module-annotated classes — a DSL module { … } property has no class to reference, so it cannot live in that annotation list. The composition is one-way: annotations declare the aggregator on top, DSL modules can be added afterward in the trailing lambda of startKoin<T> { … }:
@Module @ComponentScan @Configuration @KoinApplication
class MyApp
@Singleton class A
class B(val a: A)
val dslModule = module {
single<B>() // DSL definition — no @Module class to reference
}
// In test / app entry:
val koin = startKoin<MyApp> {
modules(dslModule) // standard KoinApplication.modules(vararg Module)
}.koin
val b = koin.get<B>() // resolves at runtime; must also resolve at compile time
Coverage:
| Layer | What's checked | How |
|---|---|---|
| A3 (full graph) | @Module-discovered definitions only | Walks @KoinApplication-discovered + @Configuration modules |
| A4 (call sites) | A3 graph + cross-module DSL hints | Phase 3.5 unions dsl_single/dsl_factory/… hints from dependency JARs |
A3 doesn't reach DSL module properties (they are values, not classes — the typed entry doesn't know which DSL modules you'll pass at the call site). A4 does, via the dsl_<defType> hints emitted in Phase 2.5 by the source set that owns the DSL module. That's enough to make koin.get<B>() resolve cleanly across the source-set boundary in the example above.
What this does not cover: dependencies inside a DSL module's own definitions are not reverse-checked against the typed entry's annotation graph at A3 time. Those are still validated locally in the source set that declares the DSL module (Phase 3.1 / A4).
Generic DSL Types
Runtime Koin resolves definitions on the erased raw class — type parameters are not part of the lookup key. Compile-safety honours that: a get<Box<X>>() call is validated against any Box<*> provider in the graph, and two single<Box<A>>() / single<Box<B>>() declarations collide on the same raw class.
val appModule = module {
single<Navigator<AppKey>>() // validated as Navigator (raw)
single<Navigator<MenuKey>>() // treated as the same definition
}
Validating on the raw class is also what makes iOS/Native builds work — emitting the generic type with its free parameter into hint functions used to crash the Kotlin/Native klib signature mangler.
Discriminating generic instances — use named<T>()
When multiple instances of the same generic class must coexist, register a concrete wrapper type and key each instance with a type qualifier derived from the generic parameter. This is the pattern used internally by koin-compose-navigation3:
// From koin-compose-navigation3
inline fun <reified T : Any> Module.navigation(
noinline definition: @Composable Scope.(T) -> Unit,
): KoinDefinition<EntryProviderInstaller> {
// Concrete type EntryProviderInstaller + type qualifier derived from T.
return _singleInstanceFactory<EntryProviderInstaller>(named<T>(), { ... })
}
// Caller side
module {
navigation<HomeRoute> { ... } // keyed by named<HomeRoute>()
navigation<SettingsRoute> { ... } // keyed by named<SettingsRoute>()
}
// Resolution uses the same type qualifier
koin.get<EntryProviderInstaller>(named<HomeRoute>())
Runtime Koin matches on (raw class + qualifier), and the compile-safety validator respects qualifier matching — so the plugin sees these as two distinct definitions, as expected. Prefer named<T>() qualifier-on-concrete-type over single<Box<X>>() directly whenever you need to distinguish generic instantiations.
Configuration
koinCompiler {
compileSafety = true // Enable/disable compile-time safety checks (default: true)
}
Safety checks are gated by KoinPluginLogger.compileSafetyEnabled, controlled by the compileSafety Gradle option.
Implementation
Architecture Overview
Validation runs in two IR phases: A3 in Phase 3/3.1, A4 in Phase 3.5/3.6. Phase 1b collects definitions and generates module bodies only — it does not validate (A1/A2 per-module validation was removed in 1.1.0; see the Validation Scopes section above).
IR Phase 0: KoinHintTransformer
└── Generate bodies for FIR-created hint functions
IR Phase 1: KoinAnnotationProcessor.collectAnnotations()
└── Discover @Module, @Singleton, @Provided, etc.
IR Phase 1b: KoinAnnotationProcessor.generateModuleExtensions()
├── For each module:
│ ├── collect local definitions
│ ├── collect cross-module definitions (hints)
│ └── generate module() function body
└── expose: collectedModuleClasses, getDefinitionsForModule()
IR Phase 2: KoinDSLTransformer
├── Transform single<T>() → single(T::class, null) { T(get()) }
├── Collect DslDef definitions for safety graph
└── Collect PendingCallSiteValidation for A4
IR Phase 2.5: generateDslDefinitionHints()
└── Generate dsl_single/dsl_factory/... hint functions for cross-module DSL discovery
IR Phase 3: KoinStartTransformer
├── visitCall(startKoin<T>) → extract @KoinApplication modules
├── A3: validateFullGraph() → validate ALL modules combined (incl. DSL definitions)
└── transform to startKoinWith(modules, lambda)
IR Phase 3.1: validateDslDefinitionGraph()
└── DSL-only A3 — when startKoin{} exists but no startKoin<T>() / @KoinApplication
IR Phase 3.5: validatePendingCallSites()
├── A4: validate get<T>(), inject<T>(), koinViewModel<T>() call sites
└── Generate callsite(required: T) hints for unresolved types (deferred validation)
IR Phase 3.6: validateCallSiteHintsFromDependencies()
└── Discover and validate call-site hints from dependency modules
IR Phase 4: KoinMonitorTransformer
└── Process @Monitor annotations
Key Components
BindingRegistry (ir/BindingRegistry.kt)
The validation engine. validateModule() is self-contained — it builds provided types from the definitions passed in, so it can be called per-module or on a combined graph.
Data types:
// Identifies a type in the DI container
data class TypeKey(
val classId: ClassId?, // for cross-module matching
val fqName: FqName? // for display and fallback matching
)
// A parameter that needs a dependency
data class Requirement(
val typeKey: TypeKey,
val paramName: String,
val isNullable: Boolean,
val hasDefault: Boolean,
val isInjectedParam: Boolean,
val isLazy: Boolean,
val isList: Boolean,
val isProperty: Boolean,
val qualifier: QualifierValue?
)
// A definition that provides a type
data class ProvidedBinding(
val typeKey: TypeKey,
val qualifier: QualifierValue?,
val scopeClass: IrClass?,
val bindings: List<TypeKey>, // auto-bound interfaces
val requirements: List<Requirement>,
val sourceName: String
)
Core method — validateModule():
validateModule(moduleName, definitions, parameterAnalyzer, qualifierExtractor)
│
├── 1. Build provided types set
│ For each definition:
│ ├── add definition's own type (e.g. Repository)
│ └── add auto-bound interfaces (e.g. IRepository)
│
├── 2. Validate each definition's requirements
│ For each definition → for each constructor parameter:
│ ├── ParameterAnalyzer classifies it as Requirement
│ ├── Requirement.requiresValidation() filters out safe params
│ ├── skip if @Provided (ProvidedTypeRegistry) or whitelisted (WHITELISTED_TYPES)
│ └── findProvider() searches the provided set
│ ├── match by FqName or ClassId
│ ├── match qualifier (StringQualifier or TypeQualifier)
│ └── check scope visibility
│
└── 3. Report missing dependencies
└── reportMissingDependency() with hints for similar bindings
Scope visibility rules:
- Root-scope providers (no
@Scope) → visible to all consumers - Same-scope providers → visible within their scope
- Cross-scope → not visible (ERROR)
ParameterAnalyzer (ir/ParameterAnalyzer.kt)
Converts IR function/constructor parameters into Requirement objects. Mirrors KoinArgumentGenerator logic but produces data instead of IR code.
Classification rules:
@InjectedParam→isInjectedParam=true→ skip validation@Property("key")→isProperty=true→ skip validationLazy<T>→isLazy=true, unwraps toTfor type matchingList<T>→isList=true→ skip validationT?→isNullable=true→ skip validation- Default value + no qualifier +
skipDefaultValues→ skip validation - Everything else → requires validation
ProvidedTypeRegistry (ProvidedTypeRegistry.kt)
Stores FQ names of types annotated with @Provided. Checked during validation alongside the hardcoded whitelist. Populated during Phase 1 (collectAnnotations()), cleared between compilation units.
QualifierExtractor (ir/QualifierExtractor.kt)
Reads qualifier annotations from parameters and definitions. Returns QualifierValue:
sealed class QualifierValue {
data class StringQualifier(val name: String) // @Named("x"), @Qualifier(name="x")
data class TypeQualifier(val irClass: IrClass) // @Qualifier(MyType::class)
}
Supports: @Named (Koin, jakarta, javax), @Qualifier (Koin), and custom qualifier annotations.
ConfigurationUtils (ir/ConfigurationUtils.kt)
hasConfigurationAnnotation(irClass: IrClass): Boolean — checks whether a module class carries
@Configuration, for module-discovery purposes (KoinStartTransformer). The label-extraction
helper this doc used to show here (extractConfigurationLabels) was deleted in 1.1.0 along with
the per-module A2 validation it only existed to feed.
AnnotationModels (ir/AnnotationModels.kt)
Unified Definition sealed class enables polymorphic handling:
sealed class Definition {
class ClassDef(val irClass: IrClass, ...) // annotated class
class FunctionDef(val irFunction: IrSimpleFunction, ...) // annotated function in @Module
class TopLevelFunctionDef(val irFunction: IrSimpleFunction, ...) // annotated top-level function
class DslDef(val irClass: IrClass, ...) // DSL definition (single<T>, factory<T>)
class ExternalFunctionDef(...) // cross-module function from hints
abstract val definitionType: DefinitionType
abstract val returnTypeClass: IrClass // the provided type
abstract val bindings: List<IrClass> // auto-bound interfaces
abstract val scopeClass: IrClass? // scope, if scoped
}
- DslDef — collected during Phase 2 (
KoinDSLTransformer) when DSL calls likesingle<T>()orfactory<T>()are transformed. Participates in A3 (Phase 3/3.1) and A4 (Phase 3.5) validation as both provider and consumer. - ExternalFunctionDef — provider-only definition discovered from cross-module function hints. Represents a tagged top-level function (
@Singleton fun provide...()) from another Gradle module. Only contributes to the provided types set; its own requirements were validated in its source module.
@Configuration Label Discovery (module discovery, not a validation pass)
@Configuration-labeled modules are auto-discovered and unioned into the entry point's module set
in KoinStartTransformer — this is the same mechanism that used to also expand per-module
validation visibility (the deleted A2). KoinConfigurationRegistry is a System property-based
registry populated during FIR phase, mapping labels to module FQ names across the FIR/IR classloader
boundary; KoinStartTransformer reads it to assemble the full module list, which then goes through
the single A3 full-graph validation below — there is no separate sibling-visibility validation step
anymore.
A3: startKoin Full-Graph Validation
In KoinStartTransformer.visitCall(), after discovering all modules from @KoinApplication:
if (KoinPluginLogger.compileSafetyEnabled && moduleClasses.isNotEmpty() && annotationProcessor != null) {
validateFullGraph(appClass, moduleClasses)
}
validateFullGraph() collects ALL definitions from ALL modules via annotationProcessor.getDefinitionsForModule(), includes DSL definitions (DslDef) passed from Phase 2, and runs BindingRegistry.validateModule() on the union.
The annotationProcessor reference is passed from KoinIrExtension (Phase 1 → Phase 3).
Cross-module DSL hint discovery
DslHintGenerator.discoverDslDefinitionTypes() scans the classpath for dsl_single/dsl_factory/dsl_scoped/dsl_viewModel/dsl_worker hint functions in org.koin.plugin.hints and returns the FQNames of every provided type encoded across their parameters. This is what surfaces upstream DSL module { single<X>() } definitions to consumer modules.
Two call sites in the aggregator consume the result:
- Phase 3.5 (
CallSiteValidator.validatePendingCallSites) — unions the hint types into A4's known-types set, sokoin.get<X>()resolves against DSL modules loaded viamodules(dslModule)at thestartKoin<T> { … }call (see Mixing@KoinApplicationwith DSL modules). - Phase 3.6 (
CallSiteValidator.validateCallSiteHintsFromDependencies) — same set used to validatecallsite(required: T)hints from downstream modules.
Both fire in the same compile, so discoverDslDefinitionTypes() is memoized via a by lazy on DslHintGenerator (instantiated once per KoinIrExtension.generate()). The underlying IrPluginContext.referenceFunctions(CallableId) scan runs once; subsequent calls return the cached Set<String>.
Test Coverage
Unit Tests
BindingRegistryTest— 26 tests covering: type matching, qualifier matching, scope visibility, nullable/lazy/list/injectedParam/default skipping, missing dependency detectionKoinAnnotationFqNamesTest— annotation FQName correctness
Box Tests (runtime verification)
In testData/box/safety/:
| Test | Validates |
|---|---|
complete_graph.kt | All deps satisfied → no error, runs OK |
nullable_ok.kt | Nullable params skip validation |
injected_param_ok.kt | @InjectedParam skips validation |
default_value_ok.kt | Default values skip validation |
lazy_valid.kt | Lazy<T> with T available → OK |
list_ok.kt | List<T> skips validation |
qualifier_match.kt | @Named qualifier matching works |
scoped_visibility.kt | Scope visibility rules |
module_includes_visible.kt | included modules' definitions are visible in the assembled graph |
configuration_group.kt | @Configuration siblings share definitions once unioned at an entry point |
startkoin_full_graph.kt | A3: startKoin<T> validates full graph |
Diagnostic Tests (compilation error verification)
In testData/diagnostics/:
| Test | Validates |
|---|---|
missing_dependency.kt | Missing non-nullable dep → ERROR |
lazy_missing.kt | Lazy<T> with T missing → ERROR |
qualifier_mismatch.kt | Wrong qualifier → ERROR with hint |
scoped_cross_scope.kt | Cross-scope dependency → ERROR |
configuration_label_mismatch.kt | Different @Configuration labels, no entry point (leaf) → silent, no diagnostic (1.1.0) |
startkoin_missing.kt | A3: full graph still missing dep → ERROR |
Each diagnostic test has .fir.txt (FIR golden file) and .errors.txt (error message golden file) for regression testing.
Current Status and Roadmap
| Phase | Scope | Status |
|---|---|---|
| Removed (1.1.0) — see Validation Scopes above | ||
@Configuration group siblings | Removed (1.1.0) — see Validation Scopes above | |
| A3 | startKoin<T> full graph — now the sole verifier | Done |
| A4 | Call-site validation (get<T>(), inject<T>(), koinViewModel<T>()) | Done |
| B | DSL calls (single<T>(), factory<T>()) in safety graph | Done |
| C | Cross-Gradle-module (definitions from dependency JARs via hints) | Done |
| C2 | Cross-module function hint metadata (qualifier, scope, bindings) | Done |
| D | @Property/@PropertyValue matching | Done |
Phase B notes: DSL definitions (single<T>(), factory<T>(), etc.) are collected as DslDef during Phase 2 and participate in the safety graph. Phase 3.1 validates their constructor parameters when no startKoin<T>() / @KoinApplication is present. Phase 2.5 generates DSL definition hints (dsl_single, dsl_factory, etc.) for cross-module discovery.
Phase A4 notes: Call sites are collected during Phase 2 and validated in Phase 3.5. Unresolved call sites in feature modules generate callsite(required: T) hint functions for deferred validation by the app module in Phase 3.6.
Phase C: Known Limitations
Cross-module class definitions have full metadata (annotations are available from JAR metadata). Cross-module top-level function definitions use ExternalFunctionDef with metadata encoded in hint function parameters (C2):
| Metadata | Encoding | Status |
|---|---|---|
@Named/@Qualifier | qualifier_<name> or qualifierType hint param | Done |
@Scope(MyScope::class) | scope hint parameter | Done |
| Bindings (supertypes) | binding0, binding1, ... hint params | Done |
Remaining limitation: Package filtering for function hints is based on the return type's package, not the function's own package. If @Singleton fun provideRepo(): Repository is in package infra but Repository is in package domain, the @ComponentScan must match domain.