When Koin DI in an app turns into a source of failures
Koin DI is convenient exactly up to the point where the dependency graph fits in your head.
As soon as an app outgrows a dozen modules, and KMM modules start sharing common code between Android and iOS, dependency resolution turns into a source of failures that surface not at build time, but at runtime — for the user.
The most common cause of a crash at startup is circular dependencies.
Koin resolves the graph lazily and at request time, so a cycle doesn't break compilation: the app builds, passes review, and then crashes on initialization of a screen or a background service when two components reference each other.
Diagnostics are complicated by the fact that the stack trace points to the request site, not to where the cycle was introduced.
The second problem is duplication of graphs between modules and platforms. The same repository is registered both in a shared KMM module and in a platform one, the definitions overlap each other, and at runtime the wrong implementation is injected.
On Android this produces different instances where one was expected; on iOS it causes behavior to diverge from Android despite formally shared code. The bug doesn't reproduce consistently and surfaces as a "flaky" issue.
A separate class of failures is implicit dependencies on initialization order. If a module is loaded later than the first request occurs, Koin can't find the definition and crashes the app.
We break down how this is closed: by validating the graph at build time, by a single registration point for modules, and by explicit rules for shared KMM modules.
What a well-structured dependency graph gives you
A well-structured dependency graph isn't about being "trendy" — it's about what happens to the app at startup.
When dependencies are assembled lazily and in layers, the app doesn't pull into memory what isn't needed on the first screen: cold start time is reduced by tens of percent, and on low-end devices the difference is more noticeable than on flagships.
We work on Koin — a declarative graph reads like regular code, without code generation or runtime reflection, so the build doesn't accumulate unnecessary steps.
A separate pain point is initialization failures. When dependencies are created manually or through singletons with side effects, one module crashing brings down the entire startup: the user sees a white screen, and the logs show a cascade of errors.
An explicit graph with build-time validation eliminates this class of failures: if a dependency isn't declared or is declared twice, we find out before release, not from incident reports.
For KMM projects, predictability matters more than speed. A shared module describes contracts once, and platform-specific parts plug in their own implementations — Android and iOS get identical behavior where the logic is shared, and don't duplicate initialization. This removes an entire layer of discrepancies that usually surface during acceptance testing.
Build speed changes too: without code generation and with clear module separation, incremental builds are faster, and the developer doesn't wait for the graph to be rebuilt after editing a single dependency.
The result — startup is predictable, initialization failures drop to zero at the build stage, and behavior on both platforms matches.
Approaches to Organizing Koin DI: A Comparison
A single graph is convenient at the start: one startKoin with a list of modules, everything visible in one place.
The problem shows up at scale — any new screen requires editing a shared file, and cold start time grows because the graph is assembled in full, including dependencies of screens the user may never reach.
Modular breakdown solves this through isolation: a feature declares its own dependencies and knows nothing about others, while the graph is assembled from independent modules.
Lazy initialization changes the moment an object is created: single is created on first access, factory — on every request, and heavy dependencies (network layer, database, analytics) are moved off the startup path.
Platform separation is critical for multiplatform projects: a common module describes contracts, while platform modules supply implementations via expect/actual — otherwise the common code starts pulling in platform-specific dependencies.
| Option | When it fits | Limitations |
|---|---|---|
| Single graph | A small application, one team, a short list of screens | Edits to a shared file for every feature, growing startup time, conflicts during parallel work |
| Modular breakdown | Multiple teams, a growing number of features, independent releases | Requires boundary discipline: a dependency leak between modules breaks isolation |
| Lazy initialization | Heavy dependencies, sensitive cold start | Configuration errors surface at runtime rather than at build time — graph checks are needed |
| Platform separation | A multiplatform project, different implementations per platform | Duplicated declarations, harder to trace which implementation is supplied |
In practice, the options are combined: modular breakdown sets the boundaries, lazy initialization removes the unnecessary from startup, and platform modules cover implementation differences.
The choice is dictated not by the size of the code, but by the number of teams and how costly cold start is for user retention.
Stages of setting up Koin DI in a project
Setting up dependency injection in an Android/KMM project is not a one-time file edit, but a sequence of steps where each subsequent one builds on the result of the previous one.
If you skip the audit or verification on one of the platforms, the dependency graph sprawls: some objects are created twice, some remain tightly coupled and are not substituted in tests. Below is the order in which we carry out the work.
- Audit of current dependencies. We go through the code and record how objects are created now: manual constructors, static factories, direct references to shared objects. We compile a list of what we are moving under container management, and mark the links that will have to be broken.
- Designing the graph schema. We determine which objects live for the entire lifetime of the application, which are tied to a screen, and which are created on each call. We distribute them across scopes so as not to keep unnecessary things in memory and not to proliferate hidden links between layers.
- Setting up modules. We describe modules by layer — data, domain, presentation — and define the rules for substituting implementations. We move shared modules into a common part, and describe platform-specific ones separately for Android and iOS.
- Verification on both platforms. We build and run the application on Android and iOS, check that the graph is built identically and that platform-specific implementations are substituted correctly. Separately, we run tests with dependency substitution — this is how we confirm that the links are truly broken.
- Handover and documentation. We record the graph schema, the rules for adding new dependencies, and the extension points. We hand it over to the customer's team with a walkthrough: what is where and how to change it without breaking the rest.
This order yields a predictable result: objects are created in one place, tests substitute dependencies without edits to production code, and adding a new platform does not require rewriting the graph. Describe your task — we will get back to you with an estimate and a plan for your project.
Artifacts after dependency injection setup
The customer receives not a "configured framework," but a set of artifacts that the team can use to continue working without the person who set up the injection. Each item is a working file or document, not a retelling of agreements.
- Module configurations — splitting the graph into modules by layer (data, domain, presentation) with explicit entry points: where dependencies are declared, where they are substituted with test ones, where platform implementations for Android and the shared part are connected.
- Dependency graph diagram — a visual map: which components depend on which, where a single instance is created, and where a new one is created per request. With it, a new engineer understands the structure in an hour, not in a week of reading code.
- Scope policies — regulations on what lives in the global scope, what is tied to a screen or session, and when it is released. Closes memory leaks and "stuck" states between screens.
- Injection documentation — how to add a new dependency, how to link a module with platform code, typical initialization errors and their causes.
- Change regulations — rules for editing the graph: who approves a new module, how it is verified that a dependency does not pull in an extra layer, what to do in case of a scope conflict.
- Set of checks — tests confirming that the graph assembles completely and that substitution with test implementations does not break the build.
- Handover instructions — the onboarding procedure: where the configurations are located, how to run the project, whom to contact if the diagram and code diverge.
The set is self-sufficient: the team can change the graph, add modules, and release versions without coming back to us for every clarification. If something already exists in the project, we will compare it against this list and supplement what is missing.
Case study: eliminating initialization failures in a KMM app
A Kotlin Multiplatform app crashed on startup for a portion of users: the dependency graph was assembled at runtime, and with a certain order of module initialization a circular dependency arose between the data layer and the cache.
The failure didn't reproduce in staging — only on devices with a cold start and a slow disk, so in reports it looked like a random crash with no stack trace. The share of failed launches held at around 4%, and support spent more time on recurring tickets than on new tasks.
What we changed
We reworked the graph: broke the cycle by extracting shared state into a separate module, switched dependency registration to explicit scopes, and added graph validation at build time rather than at runtime.
Initialization no longer depended on load order — the container is now assembled deterministically, and a faulty link is caught before release.
Metrics before and after
| Metric | Before | After |
|---|---|---|
| Share of startup failures | ~4% | <0.2% |
| Project build time | baseline | −18% |
| Cold start time | baseline | −22% |
| Investigation of initialization incidents | regular | isolated cases |
The reduction in build time came not from the DI framework itself but from dropping reflection and unnecessary modules: the graph became static, and the compiler sees the links in advance.
For us, Koin here is a tool, not a goal in itself: what matters more is that initialization failures stopped being a class of problems that can't be caught before production.
If crashes on startup in your app are being written off as a "floating bug," chances are it's the dependency graph — describe the task, and we'll come back with an estimate and an investigation plan.
How does Koin DI differ from manual dependency injection?
Manual dependency injection is when objects are created right where they are needed: the screen assembles the repository itself, the repository assembles the network client, and so on down the chain.
At the start of a project this looks harmless, but each new dependency requires changes in several places at once.
As soon as a second implementation variant appears — for example, a separate data source for tests or a different environment — you have to either duplicate the assembly or add conditions inside the working code.
Koin DI moves object assembly into a separate description: dependencies are declared once, and the consumer requests what it needs without knowing how it is structured inside. The difference shows up as the project grows.
With the manual approach, adding a new layer affects all creation points, and the larger the application, the higher the risk of forgetting one of them — the error surfaces not at build time, but at runtime, on a specific user scenario.
Testability changes fundamentally. Manual assembly forces you to substitute dependencies through real constructors, which means the test drags along the entire chain — network, storage, platform calls.
With Koin, it is enough to override the needed declaration in the test module: one node is substituted, and the rest of the configuration remains functional. The test becomes fast and does not depend on the external environment.
Speed of change is the second effect. When dependencies are described declaratively, replacing an implementation or adding a layer is an edit to a single declaration rather than a search across the entire codebase.
The team stops being afraid to touch related modules, because the assembly points are gathered in one place and are fully visible.
The boundary of applicability deserves separate mention. On a small application with a dozen classes, manual injection remains workable and does not require an additional layer.
Koin starts to pay off where several modules appear, environment-based separation, and regular changes — it is at this scale that manual assembly turns into a source of errors rather than a saving.
Technical concerns about Koin DI
Introducing a DI container almost always runs into the same engineering concerns: will working code have to be rewritten, will the app grow, and how does it live in a multi-module project. Below is a breakdown of each point.
Will existing code have to be rewritten?
No. Koin is plugged in on top of the current architecture: modules describe the dependency graph, and classes receive them through the constructor or delegated properties.
Already working screens and data layers migrate gradually, one module at a time — the old way of creating objects keeps working in parallel while you move the next section over.
How much does the app size grow?
The impact on the final artifact is minimal: the container does not pull in code generation at build time and does not add runtime reflection beyond the graph itself. The main bulk is the module descriptions, which are compiled together with the app. In practice, the increase is unnoticeable against the backdrop of UI and networking libraries.
How does this work in a multi-module project?
Each module declares its own set of dependencies and knows nothing about its neighbors' internals. The assembly module collects them into a common graph, so feature modules remain isolated, and the connections between layers are visible in one place. This simplifies both the build and parallel work by teams.
What about compatibility and rollback?
Koin gets along with any UI layer and with the libraries already in use — no conflicts arise at the dependency level.
If the solution does not fit, rollback comes down to returning to the previous way of creating objects: the graph is isolated in the module descriptions and is not hardwired throughout the code.
Test scenarios are preserved in the process — swapping dependencies for stubs remains just as simple.
Describe your project — we will come back with an estimate and a migration plan for your module structure.
Let's discuss your Koin DI task
To come back with an estimate and a plan for Koin DI, we don't need full access to the repository — we need a picture of your dependency injection module: where graphs are created, how they relate to screen lifecycles, and what's already hurting. Describe the task in free form — then we'll ask targeted questions and propose a plan.
What helps us get started and speeds up the estimate:
- the structure of the app's modules and the boundaries of shared code between the Android and multiplatform parts;
- the current approach to dependency injection: manual factories, a service locator, or already Koin — and at what point it "went off the rails";
- the list of scenarios that break: screen recreation, nested navigation, background tasks, tests;
- how scopes are organized — where a singleton is needed, where a per-screen factory is needed, where a dependency lives for exactly one user flow;
- testing requirements: which modules are substituted in checks and how much of this is currently manual work;
- release deadline constraints and what must not be touched without stopping the product.
From this data we'll put together a plan: we'll break the graphs down by module, define a migration order without downtime, and pin down how we verify that behavior hasn't changed.
If you have crash logs or a list of open defects related to dependencies, attach them — they'll refine the estimate.
Describe the task and send what you already have — we'll come back with an estimate, a work plan, and a starting order.







