Describe the bug
tl;dr: Koin has long-standing surprising behavior where calling declare on a Koin object results in state being saved across calls to stopKoin and startKoin which otherwise would not be persisted when a Module is reused. Without the declare call, Module reuse doesn't lead to this state leak, so it seems that this loophole is a bug. The Kotest testing library ships with a KoinExtension that makes it easy to use Koin modules in your tests and wraps each test function in startKoin/stopKoin calls which should provide each test function with distinct state (and it's designed to be used along with KoinTest which uses the Koin global context). Kotest v6 changed the way extensions are loaded so that they are defined once in a val instead of on each call to a fun, which means you are much more likely to share Koin Modules across test functions when you try to upgrade to Kotest v6, and thus much more likely to trigger this bug. I have a repository with a pure-Koin reproduction as well as showing how a passing Kotest v5 test becomes a failing Kotest v6 test. There are workarounds in Kotest v6 but they are either brittle or have performance impact; I think this should be fixed in Koin and will file a small PR to fix it. (For transparency, I wrote this paragraph myself from scratch; the rest of this issue was drafted with Claude though I've edited it.)
When a binding is overridden via koin.declare(...) (or any InstanceRegistry.saveMapping call with allowOverride = true), the displaced InstanceFactory is replaced in InstanceRegistry._instances but its cached value is never dropped. Because Module.mappings still holds the original factory by reference, that factory survives stopKoin() with its old singleton intact. The next startKoin { modules(sameModuleInstance) } re-registers the still-cached factory, and the next get<T>() returns a stale instance — including any state mutations from the previous Koin lifecycle.
This is a long-standing latent bug present in every released version I checked (3.x, 4.0, 4.1, 4.2.1, current main). It happens whenever (a) the same Module instance is loaded across multiple stopKoin/startKoin cycles and (b) one of its bindings is overridden with declare. The leak is observable — i.e. the user notices that they got back the previous lifecycle's singleton — when the singleton has identity-distinguishable state: mutated fields, recorded mock interactions, captured spy state, etc. Otherwise the same instance is silently returned but looks functionally fresh. That combination is uncommon in production code but routine in test setups — and the population of users hitting this just expanded sharply with kotest 6 (see "Additional context" below).
Issue #2001/#2210 are very related: one is avoiding eagerly initializing a factory that may be displaced, and this is cleaning up an already-sed factory that is being replaced.
To Reproduce
See below for a Git repository with self-contained reproductions. The steps that trigger this:
- Build a
Module with a single { ... } binding and store it in a val that outlives a single Koin lifecycle (e.g., a top-level property).
startKoin { modules(sharedModule) }.
get<T>() and mutate the resulting singleton.
koin.declare<T>(SomethingElse()) to override the binding mid-lifecycle.
stopKoin().
startKoin { modules(sharedModule) } (the same Module instance).
get<T>().
Observed: step 7 returns the same instance from step 3 with the mutation from step 3 still present. (It doesn't return the instance from the declare itself — Koin properly resets back to the right Module. But the Module itself isn't properly cleaned up.)
Expected behavior
After step 5 (stopKoin()), the cached value of every SingleInstanceFactory in sharedModule.mappings should be dropped — including the one that was displaced by declare. So step 7 should return a fresh instance whose state is whatever the single { ... } definition produces, not the mutated value from step 3.
Koin module and version:
io.insert-koin:koin-core:4.2.1 (also reproducible against main at commit 7fe8c527, and against every earlier version I checked back to 3.x — not a recent regression).
Snippet or Sample project to help reproduce
A self-contained reproduction repo with three Gradle subprojects is available at:
https://github.com/glasser/koin-declare-reproduction
| Subproject |
Versions |
./gradlew :sub:test |
pure-koin |
koin-core 4.2.1 |
fails — direct repro, no test framework |
kotest-5 |
kotest 5.9.1 + io.kotest.extensions:kotest-extensions-koin 1.3.0 + koin 3.5.6 |
passes — fun extensions() masks the bug |
kotest-6 |
kotest 6.1.11 + io.kotest:kotest-extensions-koin 6.1.11 + koin 4.2.1 |
fails for Kotest6DeclareSpec; passes for Kotest6InstancePerRootSpec (workaround) |
The same logical test scenario passes on kotest 5 and fails on kotest 6 — demonstrating that the user-observable behavior changed between kotest versions with no koin code change. The Kotest6InstancePerRootSpec shows that IsolationMode.InstancePerRoot is a viable spec-level workaround, but see below for why this isn't ideal.
The koin-only repro inlined for convenience:
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.koin.mp.KoinPlatform
interface Repo { var state: Int; val tag: String }
class RealRepo(override val tag: String) : Repo { override var state: Int = 0 }
fun main() {
val sharedModule = module {
single<Repo> { RealRepo("from-module") }
}
// Cycle 1: get the singleton, mutate it, then declare-override and stop.
startKoin { modules(sharedModule) }
val first = KoinPlatform.getKoin().get<Repo>()
first.state = 999
KoinPlatform.getKoin().declare<Repo>(RealRepo("from-declare"))
stopKoin()
// Cycle 2: reload the same module instance and read the binding.
startKoin { modules(sharedModule) }
val second = KoinPlatform.getKoin().get<Repo>()
println("tag=${second.tag} state=${second.state} same=${first === second}")
// Actual: tag=from-module state=999 same=true
// Expected: tag=from-module state=0 same=false
}
Without the declare step, stopKoin/startKoin resets the singleton correctly. The leak is specifically introduced by the override.
Why do we call declare in our tests after already calling get? A lot of our tests do something like this (calling KoinTest.declare):
val fooSpy = declare { spy(get<Foo>()) }
ie, they use the properly initialized Koin module to extract a Foo object, wrap it in a "spy" implementation that implements the same interface with the ability to spy on what it's doing, and then re-declare that as the main implementation in the Koin context. (We make sure to do this at the very beginning of the test before anything else has a chance to fetch a Foo.)
Additional context
Why kotest 6 newly exposes this
In kotest 5.x, Spec exposed extensions via a function:
abstract class Spec : TestConfiguration() {
open fun extensions(): List<Extension> = listOf()
}
A user overriding it as override fun extensions() = listOf(KoinExtension(module { ... })) got a fresh module { } (and therefore fresh InstanceFactory objects) on every call — and kotest's engine calls spec.extensions() at multiple points per spec lifecycle. The koin bug existed, but the displaced factory's stale state was thrown away with the old module each iteration, so it didn't surface.
Kotest 6 (PR kotest/kotest#4640, commit 02573516e, merged 2025-01-12, released in v6.0.0) changed it to a property:
abstract class Spec : TestConfiguration() {
open val extensions: List<Extension> = emptyList()
}
A val — so the natural override val extensions = listOf(KoinExtension(module { ... })) is evaluated once at spec construction, and under the default IsolationMode.SingleInstance the spec is constructed once per spec class. The KoinExtension and its captured Module are now reused across every test in the spec, which is exactly the input shape that triggers the latent koin bug.
So: anyone using kotest-extensions-koin with kotest 6, with declare/declareMock to override a binding mid-test, will see test-to-test state pollution where they didn't before. The koin bug is the same bug that's always been there; kotest 6 just removed the accidental masking.
There are Kotest-level workarounds, but they're not ideal:
- You can put a
get()ter on your override val extensions but that only works because Kotest happens to re-read this property often enough rather than caching it, as you might think it might do with a val!
- You can use
IsolationMode.InstancePerRoot which will require the whole Spec object somewhat more often, though this will prevent you from sharing other setup intentionally across test functions, and only fixes the issue across root test functions, not other ones (the related IsolationMode values that are more precise are deprecated in v6).
It would be better to fix this in Koin so that stopKoin/startKoin is more effective.
Root cause
InstanceRegistry.saveMapping (projects/core/koin-core/src/commonMain/kotlin/org/koin/core/registry/InstanceRegistry.kt:81) overwrites the registry mapping but does not drop the displaced factory:
fun saveMapping(
allowOverride: Boolean,
mapping: IndexKey,
factory: InstanceFactory<*>,
logWarning: Boolean = true,
) {
_instances[mapping]?.let {
if (!allowOverride) {
throwOverrideError(factory, mapping)
} else if (logWarning) {
_koin.logger.warn("(+) override index '$mapping' -> '${factory.beanDefinition}'")
// remove previous eager isntance too
val existingFactory = eagerInstances.values.firstOrNull { it.beanDefinition == factory.beanDefinition }
if (existingFactory != null) {
eagerInstances.remove(factory.beanDefinition.hashCode())
}
}
// <-- no dropAll() on the displaced factory
}
_koin.logger.debug("(+) index '$mapping' -> '${factory.beanDefinition}'")
_instances[mapping] = factory
}
After declare:
_instances[K] points at the new declare-owned factory.
module.mappings[K] still points at the original factory, which still has value = <pre-declare singleton>.
Koin.close() → instanceRegistry.close() iterates _instances and calls dropAll() on what it finds — i.e., only the declare-owned factory. The orphaned original is never reached, so its SingleInstanceFactory.value (and any onClose hook) is preserved.
startKoin { modules(sameModule) } then calls loadModule (InstanceRegistry.kt:71), which re-registers module.mappings's factories — including the orphan with its stale value. The next resolution returns it.
Proposed fix
Two lines: when saveMapping displaces a different factory, drop the displaced one's cached state before replacing it.
fun saveMapping(
allowOverride: Boolean,
mapping: IndexKey,
factory: InstanceFactory<*>,
logWarning: Boolean = true,
) {
_instances[mapping]?.let { displaced ->
if (!allowOverride) {
throwOverrideError(factory, mapping)
} else {
if (logWarning) {
_koin.logger.warn("(+) override index '$mapping' -> '${factory.beanDefinition}'")
val existingFactory = eagerInstances.values.firstOrNull { it.beanDefinition == factory.beanDefinition }
if (existingFactory != null) {
eagerInstances.remove(factory.beanDefinition.hashCode())
}
}
if (displaced !== factory) {
displaced.dropAll()
}
}
}
_koin.logger.debug("(+) index '$mapping' -> '${factory.beanDefinition}'")
_instances[mapping] = factory
}
The displaced !== factory guard preserves the no-op behavior when the same factory is re-registered (e.g., loading a module twice in the same lifecycle).
Verification
I will open a PR with my fix and a regression test. With the patch:
- The new test passes; without the patch, the new test fails on the
assertNotSame(first, second) and assertEquals(0, second.value) assertions (i.e., the bug is observable).
./gradlew :core:koin-core:jvmTest runs 284/284 tests passing.
- All existing
DeclareInstanceTest tests still pass — no behavior change for the documented declare/override semantics, only for the latent cache leak.
Versions affected
Confirmed by reading source at:
| Version |
saveMapping drops displaced factory? |
main (commit 7fe8c527) |
no |
origin/4.2.2 |
no |
4.2.1 (latest release) |
no |
4.0.0 |
no |
3.5.0 |
no |
3.0.1 (older API shape, same behavior) |
no |
So this is not a regression — it has always been this way.
Describe the bug
tl;dr: Koin has long-standing surprising behavior where calling
declareon a Koin object results in state being saved across calls tostopKoinandstartKoinwhich otherwise would not be persisted when aModuleis reused. Without thedeclarecall,Modulereuse doesn't lead to this state leak, so it seems that this loophole is a bug. The Kotest testing library ships with aKoinExtensionthat makes it easy to use Koin modules in your tests and wraps each test function instartKoin/stopKoincalls which should provide each test function with distinct state (and it's designed to be used along withKoinTestwhich uses the Koin global context). Kotest v6 changed the way extensions are loaded so that they are defined once in avalinstead of on each call to afun, which means you are much more likely to share KoinModules across test functions when you try to upgrade to Kotest v6, and thus much more likely to trigger this bug. I have a repository with a pure-Koin reproduction as well as showing how a passing Kotest v5 test becomes a failing Kotest v6 test. There are workarounds in Kotest v6 but they are either brittle or have performance impact; I think this should be fixed in Koin and will file a small PR to fix it. (For transparency, I wrote this paragraph myself from scratch; the rest of this issue was drafted with Claude though I've edited it.)When a binding is overridden via
koin.declare(...)(or anyInstanceRegistry.saveMappingcall withallowOverride = true), the displacedInstanceFactoryis replaced inInstanceRegistry._instancesbut its cached value is never dropped. BecauseModule.mappingsstill holds the original factory by reference, that factory survivesstopKoin()with its old singleton intact. The nextstartKoin { modules(sameModuleInstance) }re-registers the still-cached factory, and the nextget<T>()returns a stale instance — including any state mutations from the previous Koin lifecycle.This is a long-standing latent bug present in every released version I checked (3.x, 4.0, 4.1, 4.2.1, current
main). It happens whenever (a) the sameModuleinstance is loaded across multiplestopKoin/startKoincycles and (b) one of its bindings is overridden withdeclare. The leak is observable — i.e. the user notices that they got back the previous lifecycle's singleton — when the singleton has identity-distinguishable state: mutated fields, recorded mock interactions, captured spy state, etc. Otherwise the same instance is silently returned but looks functionally fresh. That combination is uncommon in production code but routine in test setups — and the population of users hitting this just expanded sharply with kotest 6 (see "Additional context" below).Issue #2001/#2210 are very related: one is avoiding eagerly initializing a factory that may be displaced, and this is cleaning up an already-sed factory that is being replaced.
To Reproduce
See below for a Git repository with self-contained reproductions. The steps that trigger this:
Modulewith asingle { ... }binding and store it in avalthat outlives a single Koin lifecycle (e.g., a top-level property).startKoin { modules(sharedModule) }.get<T>()and mutate the resulting singleton.koin.declare<T>(SomethingElse())to override the binding mid-lifecycle.stopKoin().startKoin { modules(sharedModule) }(the sameModuleinstance).get<T>().Observed: step 7 returns the same instance from step 3 with the mutation from step 3 still present. (It doesn't return the instance from the
declareitself — Koin properly resets back to the rightModule. But theModuleitself isn't properly cleaned up.)Expected behavior
After step 5 (
stopKoin()), the cached value of everySingleInstanceFactoryinsharedModule.mappingsshould be dropped — including the one that was displaced bydeclare. So step 7 should return a fresh instance whose state is whatever thesingle { ... }definition produces, not the mutated value from step 3.Koin module and version:
io.insert-koin:koin-core:4.2.1(also reproducible againstmainat commit7fe8c527, and against every earlier version I checked back to 3.x — not a recent regression).Snippet or Sample project to help reproduce
A self-contained reproduction repo with three Gradle subprojects is available at:
https://github.com/glasser/koin-declare-reproduction
./gradlew :sub:testpure-koinkoin-core4.2.1kotest-5kotest5.9.1 +io.kotest.extensions:kotest-extensions-koin1.3.0 +koin3.5.6fun extensions()masks the bugkotest-6kotest6.1.11 +io.kotest:kotest-extensions-koin6.1.11 +koin4.2.1Kotest6DeclareSpec; passes forKotest6InstancePerRootSpec(workaround)The same logical test scenario passes on kotest 5 and fails on kotest 6 — demonstrating that the user-observable behavior changed between kotest versions with no koin code change. The
Kotest6InstancePerRootSpecshows thatIsolationMode.InstancePerRootis a viable spec-level workaround, but see below for why this isn't ideal.The koin-only repro inlined for convenience:
Without the
declarestep,stopKoin/startKoinresets the singleton correctly. The leak is specifically introduced by the override.Why do we call
declarein our tests after already callingget? A lot of our tests do something like this (callingKoinTest.declare):ie, they use the properly initialized Koin module to extract a
Fooobject, wrap it in a "spy" implementation that implements the same interface with the ability to spy on what it's doing, and then re-declare that as the main implementation in the Koin context. (We make sure to do this at the very beginning of the test before anything else has a chance to fetch aFoo.)Additional context
Why kotest 6 newly exposes this
In kotest 5.x,
Specexposed extensions via a function:A user overriding it as
override fun extensions() = listOf(KoinExtension(module { ... }))got a freshmodule { }(and therefore freshInstanceFactoryobjects) on every call — and kotest's engine callsspec.extensions()at multiple points per spec lifecycle. The koin bug existed, but the displaced factory's stale state was thrown away with the old module each iteration, so it didn't surface.Kotest 6 (PR kotest/kotest#4640, commit
02573516e, merged 2025-01-12, released inv6.0.0) changed it to a property:A
val— so the naturaloverride val extensions = listOf(KoinExtension(module { ... }))is evaluated once at spec construction, and under the defaultIsolationMode.SingleInstancethe spec is constructed once per spec class. TheKoinExtensionand its capturedModuleare now reused across every test in the spec, which is exactly the input shape that triggers the latent koin bug.So: anyone using
kotest-extensions-koinwith kotest 6, withdeclare/declareMockto override a binding mid-test, will see test-to-test state pollution where they didn't before. The koin bug is the same bug that's always been there; kotest 6 just removed the accidental masking.There are Kotest-level workarounds, but they're not ideal:
get()ter on youroverride val extensionsbut that only works because Kotest happens to re-read this property often enough rather than caching it, as you might think it might do with aval!IsolationMode.InstancePerRootwhich will require the whole Spec object somewhat more often, though this will prevent you from sharing other setup intentionally across test functions, and only fixes the issue across root test functions, not other ones (the relatedIsolationModevalues that are more precise are deprecated in v6).It would be better to fix this in Koin so that
stopKoin/startKoinis more effective.Root cause
InstanceRegistry.saveMapping(projects/core/koin-core/src/commonMain/kotlin/org/koin/core/registry/InstanceRegistry.kt:81) overwrites the registry mapping but does not drop the displaced factory:After
declare:_instances[K]points at the new declare-owned factory.module.mappings[K]still points at the original factory, which still hasvalue = <pre-declare singleton>.Koin.close()→instanceRegistry.close()iterates_instancesand callsdropAll()on what it finds — i.e., only the declare-owned factory. The orphaned original is never reached, so itsSingleInstanceFactory.value(and anyonClosehook) is preserved.startKoin { modules(sameModule) }then callsloadModule(InstanceRegistry.kt:71), which re-registersmodule.mappings's factories — including the orphan with its stalevalue. The next resolution returns it.Proposed fix
Two lines: when
saveMappingdisplaces a different factory, drop the displaced one's cached state before replacing it.The
displaced !== factoryguard preserves the no-op behavior when the same factory is re-registered (e.g., loading a module twice in the same lifecycle).Verification
I will open a PR with my fix and a regression test. With the patch:
assertNotSame(first, second)andassertEquals(0, second.value)assertions (i.e., the bug is observable)../gradlew :core:koin-core:jvmTestruns 284/284 tests passing.DeclareInstanceTesttests still pass — no behavior change for the documenteddeclare/override semantics, only for the latent cache leak.Versions affected
Confirmed by reading source at:
saveMappingdrops displaced factory?main(commit7fe8c527)origin/4.2.24.2.1(latest release)4.0.03.5.03.0.1(older API shape, same behavior)So this is not a regression — it has always been this way.