Your dependency graph has six entry points and one of them runs twice
Application.onCreate, a Swift app init, a main function and a page load each start the graph. Two inits on one platform is a duplicate-definition crash at the worst moment.
TL;DR
startKoin { }ran twice on Android — once fromApplication.onCreateand once from aContentProvidera third-party SDK had installed — and crashed withKoinAppAlreadyStartedExceptionon 0.8% of cold starts, 340 crashes a day, only on devices where that SDK's provider initialised after ours.- A KMP app has one graph and six entry points, each owned by a different runtime. We had written three of them. Only one of the six is guaranteed to run exactly once, and two can run concurrently with another.
- The fix makes initialisation idempotent and single-threaded at the point where the platform cannot promise either — which is cheaper than finding every caller.
- What reading on buys: the five entry points enumerated, which ones can double-fire, and the assertion that catches a second init in a test rather than in production.
The case this post is about
One shared module list, started per platform:
// core/di/src/commonMain/kotlin/Modules.kt
val sharedModules = listOf(networkModule, databaseModule, repositoryModule, viewModelModule)
// androidMain
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@App)
modules(sharedModules + androidModule)
}
}
}
// iosApp
@main struct MyApp: App {
init() { KoinKt.doInitKoin() }
var body: some Scene { WindowGroup { ContentView() } }
}
// desktopMain
fun main() {
startKoin { modules(sharedModules + desktopModule) }
application { Window(onCloseRequest = ::exitApplication) { App() } }
}
Three entry points, hand-written, each looking like the only one on its platform. There are six, and the three we did not write are the ones this post is about.
The symptom
A crash that only happened on launch, only on Android, and only sometimes:
org.koin.core.error.KoinAppAlreadyStartedException: A Koin Application has already been started
at org.koin.core.context.GlobalContext.startKoin(GlobalContext.kt:41)
at com.example.App.onCreate(App.kt:12)
The stack points at our Application.onCreate — which runs once, by contract — so the crash reads
as impossible:
# crashes per day, 2 weeks
total 340/day
cold-start crash rate 0.8%
affected devices Android 12+, specific OEM builds
reproducible locally no (23 attempts)
Twenty-three local attempts, zero reproductions. And the thing that finally identified it was a single line in the crash breadcrumbs:
-2ms AnalyticsInitProvider.onCreate
-1ms startKoin (from AnalyticsInitProvider) ← a ContentProvider we did not write
0ms App.onCreate
0ms startKoin → KoinAppAlreadyStartedException
A third-party SDK shipped a ContentProvider that called into our initialisation — because our
own Koin setup had been helpfully extracted into a function that the SDK's integration guide told
people to call from an initializer.

Why the obvious fix failed
The first fix was a guard flag:
private var started = false
fun initKoin() {
if (started) return
started = true
startKoin { … }
}
Which reduced the crash rate from 0.8% to 0.1% and did not remove it. ContentProvider.onCreate
and Application.onCreate both run on the main thread, so this should be safe — and on the
affected OEM builds the breadcrumbs showed the two inits 1 ms apart, with a WorkManager
initializer on a background thread in between. A non-volatile Boolean across two threads is a
read that can miss a write.
The second fix was try/catch:
try { startKoin { … } } catch (e: KoinAppAlreadyStartedException) { /* fine */ }
Crash gone. Also: whichever init won the race defined the graph, and the two call sites passed
different module lists — the SDK's path omitted androidModule, so an app that lost the race ran
without its Android-specific bindings and failed later with NoBeanDefFoundException for
Context. Trading a startup crash for a mid-session one is not an improvement.
The third attempt was to find and remove the other caller, which worked for that SDK and is not a fix: the exported function remains callable, the integration guide still says to call it, and the next SDK will do the same thing.
The actual mechanism
A KMP app's graph has as many entry points as it has runtimes, and the guarantees differ:
entry point runs once? can run concurrently?
Application.onCreate (android) yes, per process no
ContentProvider.onCreate (android) once per provider — YES — with other providers and
and there are N with Application.onCreate on some
OEM builds
androidx Initializer (Startup) once per component yes, if a component is on a worker
SwiftUI App.init (ios) once per launch — no — but SwiftUI may construct the
documented App VALUE more than once
fun main (desktop) yes no
page load / module eval (web) once per document no — but a hot-reloaded module in
dev re-evaluates
Three of six can fire more than once, and two can fire concurrently with another entry point.
Application.onCreate is the only one with both guarantees, and it is the one the code was written
against.
And a ContentProvider is an initialisation hook that other people control. Any dependency can
declare one in its manifest, and the merged manifest decides the order. You do not own the list, so
"find every caller" is not a stable property — it is true until the next dependency bump.
Plus the two-list problem, which is the dangerous half. startKoin is not just "start"; it
defines the graph. Two callers with different module lists means the winner determines what
exists. A catch that swallows the second call silently accepts whichever list arrived first, and
the failure moves to the first missing binding — far from the cause, with a stack trace naming a
Context rather than an initialisation race.

The fix
Make initialisation idempotent, serialised, and list-authoritative: one place owns the module list, a second call is a no-op rather than a competitor, and a second call with a different list is a loud failure rather than a silent one.
// core/di/src/commonMain/kotlin/AppGraph.kt
/**
* The single owner of graph startup.
*
* Idempotent and serialised because the PLATFORM cannot promise either: a third-party
* ContentProvider called our init 1 ms before Application.onCreate on some OEM builds
* (340 crashes/day, 0.8% of cold starts), and a non-volatile guard flag across the two threads
* involved still leaked 0.1%.
*
* NOT a try/catch around startKoin: that silently accepts whichever module list arrived first,
* and the two call sites passed different lists — so the app ran without androidModule and
* failed later with NoBeanDefFoundException<Context>, a mid-session crash instead of a
* startup one.
*/
object AppGraph {
private val lock = SynchronizedObject()
private var startedWith: Set<String>? = null
fun start(modules: List<Module>, extra: KoinApplication.() -> Unit = {}) {
val fingerprint = modules.flatMap { m -> m.mappings.keys.map { it.toString() } }.toSet()
synchronized(lock) {
val existing = startedWith
if (existing != null) {
// A second call with the SAME graph is a benign double-init — the exact
// situation the ContentProvider created, and the one we want to absorb.
if (existing == fingerprint) return
// A second call with a DIFFERENT graph is the bug the catch was hiding. Fail
// here, naming what differs, rather than at the first missing binding.
val missing = existing - fingerprint
val added = fingerprint - existing
error(
"the DI graph was already started with a different module set.\n" +
" only in the first start: ${missing.take(5)}\n" +
" only in this call: ${added.take(5)}\n" +
" Both callers must use AppGraph.start(appModules) — see core/di/AppGraph.kt"
)
}
startKoin { modules(modules); extra() }
startedWith = fingerprint
startCountForTest++
}
}
/** Tests only: how many times startKoin actually ran, which is the property under test. */
var startCountForTest: Int = 0
private set
/** Tests only: a fresh graph per test, since the object survives between them. */
fun resetForTest() = synchronized(lock) {
stopKoin(); startedWith = null; startCountForTest = 0
}
}
// androidMain — the list lives in ONE place, and every entry point calls the same function
object AndroidGraph {
val modules get() = sharedModules + androidModule
}
class App : Application() {
override fun onCreate() {
super.onCreate()
AppGraph.start(AndroidGraph.modules) { androidContext(this@App) }
}
}
// androidMain — and the hook other people's SDKs reach for is now safe by construction.
// A ContentProvider is an init hook that DEPENDENCIES control: any of them can declare one,
// the merged manifest decides the order, and we never own the list. So the exported entry
// point is made idempotent rather than hidden.
class GraphInitProvider : ContentProvider() {
override fun onCreate(): Boolean {
AppGraph.start(AndroidGraph.modules) { androidContext(context!!.applicationContext) }
return true
}
// the rest of the ContentProvider surface: not a data provider
override fun query(…) = null
override fun insert(…) = null
…
}
And the test, which asserts the property rather than the absence of a crash:
// commonTest/kotlin/AppGraphTest.kt
class AppGraphTest {
@AfterTest fun tearDown() = AppGraph.resetForTest()
@Test
fun aSecondStartWithTheSameModulesIsANoOp() {
AppGraph.start(testModules)
AppGraph.start(testModules) // the ContentProvider case
assertNotNull(KoinPlatformTools.defaultContext().getOrNull())
}
@Test
fun aSecondStartWithDifferentModulesFails() {
// The case try/catch swallowed, which produced a mid-session NoBeanDefFoundException.
AppGraph.start(testModules)
val e = assertFailsWith<IllegalStateException> {
AppGraph.start(testModules + module { single { "extra" } })
}
assertContains(e.message!!, "already started with a different module set")
}
@Test
fun concurrentStartsResolveToOneGraph() = runTest {
// 1 ms apart on two threads is what the breadcrumbs showed; a non-volatile Boolean
// leaked 0.1% of the time, so the guard is a lock rather than a flag.
List(16) { async(Dispatchers.Default) { AppGraph.start(testModules) } }.awaitAll()
assertEquals(1, AppGraph.startCountForTest)
}
}
And the output it produced:
$ ./gradlew :core:di:allTests --tests '*AppGraph*'
aSecondStartWithTheSameModulesIsANoOp PASSED × 4 targets
aSecondStartWithDifferentModulesFails PASSED × 4 targets
concurrentStartsResolveToOneGraph PASSED × 4 targets
$ ./tools/crash-report.sh --issue KoinAppAlreadyStarted --days 14
before 340/day (0.8% of cold starts)
after 0/day
$ ./tools/crash-report.sh --issue NoBeanDefFound --days 14
before 4/day (the try/catch week)
after 0/day
How we proved it
That a second caller existed. The breadcrumb that ended two weeks of "this crash is impossible":
-2ms AnalyticsInitProvider.onCreate ← not our code
-1ms startKoin
0ms App.onCreate
0ms startKoin → KoinAppAlreadyStartedException
That the two callers passed different lists. The measurement that ruled out try/catch, and
the reason it mattered more than the crash:
caller modules passed androidContext?
App.onCreate shared + androidModule yes
AnalyticsInitProvider shared no
Whichever won defined the graph. The SDK's path had no Context binding at all.
That a non-volatile flag is not enough. Measured in production, because the two inits are nominally both on the main thread:
guard crash rate
none 0.8%
private var started = false 0.1% ← still leaking
synchronized + fingerprint 0.0%
The breadcrumbs showed a WorkManager initializer on a worker thread between the two calls on the
affected builds.
That we do not own the provider list. Counted, since "remove the other caller" was the tempting fix:
$ ./gradlew :app:processReleaseManifest && grep -c '<provider' app/build/.../AndroidManifest.xml
7
$ # providers we wrote
1
Six initialisation hooks installed by dependencies, in an order the manifest merger decides.
That SwiftUI can construct the App value more than once. Checked after the Android fix,
because the same class of assumption was in the iOS code:
instrumented MyApp.init(), 40 launches
init called once 38
init called twice 2 ← on a scene reconnection after a backgrounded launch
Two of forty. The idempotent start absorbs it; before the fix it was a latent crash on iOS that we
had never seen in the wild and could not have ruled out.
That the fingerprint catches a real mistake. The error fired once during the migration, which is the only evidence worth having for an assertion:
$ ./gradlew :app:assembleDebug && adb install … && adb shell am start …
FATAL: the DI graph was already started with a different module set.
only in this call: [androidx.work.WorkManager, com.example.SyncScheduler]
Both callers must use AppGraph.start(appModules)
That is the entry point that put the third row in the table above. We had enumerated
Application.onCreate, the ContentProvider we had just learned about, and the three
non-Android ones — and missed an androidx.startup Initializer added for WorkManager, with a
module list of its own. The assertion found it on the first debug launch after the migration,
which is the only kind of evidence an assertion can offer.
What I cannot tell you is whether the module fingerprint is sound for every Koin feature. It hashes binding keys, so two lists that bind the same keys to different implementations compare as equal and the second call is absorbed silently. That is the right behaviour for the double-init case and would hide a genuine misconfiguration, and we have no case where two callers legitimately bind the same key differently.
And an honest consequence. AppGraph is global mutable state with a lock, which is exactly the
shape DI exists to avoid. The justification is that graph startup is already global — startKoin
writes a process-wide singleton — so this makes an existing global explicit rather than adding one.
It also means tests need resetForTest, and a test that forgets it inherits the previous test's
graph.

What this generalizes to
Process initialisation has as many entry points as the platform has runtimes, and the number is not fixed by you: a dependency can add one. Code written against the entry point that does guarantee single execution reads as correct and is a race on every other one. So the robust shape is not to enumerate the callers — that list changes on the next dependency bump — but to make the initialisation itself idempotent, serialised, and loud when two callers disagree about what they are initialising, because that disagreement is the failure that outlives the crash.
Related
- Koin modules that compile everywhere — the module list this post guards, and how it is shaped per target.
Dispatchers.Mainthrows on a target you forgot to configure — another case of writing against the one platform whose guarantee is strongest.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".