
Dependency Injection Explained: Lessons from Spring for Mobile Devs
You have been told to "just use DI" a hundred times. Someone hands you a codebase, points at an @Inject annotation, and expects the mental model to arrive by osmosis. Spring dependency injection is where that pattern stopped being an academic curiosity and became the industry default — and Spring treats DI not as a library trick but as an architectural philosophy about who owns your object graph. That distinction matters more on mobile than most tutorials admit. Borrow how Spring reasons about wiring, and you get a sharper lens for assembling a clean Kotlin Multiplatform app with Koin — one where the shared module stays swappable, testable, and free of the god-object rot that quietly kills cross-platform codebases. This is not a framework tour. It is a way of thinking, translated from the JVM into the constraints you actually ship against.

Table of Contents
- The Problem DI Actually Solves (And Why You Feel It on Mobile)
- How Spring Wires an App — The Three Injection Styles
- The Container Question — Spring's ApplicationContext vs. Koin's Module Graph
- Translating Spring's Lessons into a KMP Architecture
- Where DI Goes Wrong — Anti-Patterns Spring Devs Learned the Hard Way
- A Pre-Ship DI Wiring Checklist for Your Next KMP App
- Frequently Asked Questions
The Problem DI Actually Solves (And Why You Feel It on Mobile)
Every time you wire a class, you make a decision — usually without noticing it. You either hand-wire the dependency, reach into a service locator, or hand the wiring to a container. Most mobile developers pick the first option by reflex and never realize it was a choice.
Here is the scenario that bites. Your ViewModel needs data, so it constructs its own Repository with Repository. That Repository, in turn, constructs its own HttpClient. Clean-looking code. Three lines. And it is quietly toxic for three specific reasons.
First, the ViewModel is now impossible to unit test without a live network stack. You cannot swap in a fake Repository that returns canned responses, because the ViewModel builds the real one internally. Your test either hits the network or does not run.
Second, the dependency is invisible. Nothing about the class signature tells a caller that this ViewModel needs a network client. The requirement is buried in the constructor body, discovered only when something breaks at runtime.
Third — and this is the part that stings on Kotlin Multiplatform — the tight coupling crosses the shared/platform boundary. Your shared module hard-references an HttpClient implementation that should be platform-specific. iOS and Android resolve networking, secure storage, and local persistence differently. The moment your common code constructs a concrete implementation, you have welded the shared layer to a detail it was never supposed to know about.
Spring formalized the escape hatch decades ago and gave it a name: Inversion of Control (IoC). Baeldung's tutorials on IoC and DI in Spring describe it precisely — control over object creation and wiring is transferred out of application code and handed to a container or framework, rather than hard-coded inside the classes that need those objects. Your ViewModel stops being a factory. It becomes a consumer.
Dependency injection isn't about frameworks — it's about who gets to decide what your objects depend on.
IoC is the principle. Dependency injection is the mechanism that implements it. An assembler or container connects objects by setting their dependencies from the outside, instead of letting each object construct or look up its own collaborators. The r/java community's IoC-versus-DI discussions land on the same distinction: IoC is the what, DI is the how. Miss that separation and you will keep treating DI as an annotation ritual rather than a design lever.
This is not just aesthetics. Faculty researchers in computer science and software engineering at California Polytechnic State University studied the question directly. According to the Cal Poly CSSE paper "Effects of Dependency Injection on Maintainability", applying DI reduced direct dependencies between modules and improved maintainability metrics compared with manual wiring or service locator patterns. The gains are measurable, not vibes.
Tie it back to your actual work. The whole point of DI is deciding who owns the wiring so your objects stay dumb, swappable, and testable. On mobile, where the platform boundary is a hard architectural fact rather than a suggestion, that decision is the difference between a shared module you can reason about and one that rots.
How Spring Wires an App — The Three Injection Styles
Before you translate anything to mobile, you need the vocabulary Spring popularized. Spring supports three injection styles, and each one shapes how dependencies enter the object graph, per Baeldung's coverage of DI in Spring: constructor injection, setter injection, and field injection.
| Style | Testability | Immutability support | Null-safety | Works without container |
|---|---|---|---|---|
| Constructor | High — just pass args | Yes (val) |
Enforced at construction | Yes |
| Setter | Medium — set after build | No (mutable) | Deferred / risky | Yes |
| Field | Low — needs reflection | No | Nullable until wired | No |
Spring's own guidance rates constructor injection as the recommended default, treats setter injection as an option for genuinely optional dependencies, and discourages field injection in production code.
Constructor injection won the argument in both the Spring and Kotlin communities, and the reasons are concrete. It enforces non-null, mandatory dependencies at construction time — the object cannot exist in a half-built state, a point the r/SpringBoot constructor-versus-field threads hammer repeatedly. It supports immutability, which in Kotlin means you declare your dependencies as val and the object is fully formed the instant it exists. And it works without any container at all. In a test, you just new it and pass fakes as arguments. No reflection, no context, no ceremony.
Field injection is the seductive shortcut — one annotation, no boilerplate constructor. It also hides dependencies from the constructor, makes classes harder to reason about, and complicates testing and refactoring. That is why Spring's reference documentation discourages it in production. But the nuance is worth keeping: the Spring Framework reference on dependency injection of test fixtures notes that field injection is acceptable in test fixtures, since tests are not part of the core application and benefit from concise setup. Production code and test scaffolding are held to different standards, and Spring says so explicitly.
Setter injection sits in the middle. It lets you configure optional or reconfigurable dependencies after construction, but heavy use invites partially initialized objects and ordering bugs. You gain flexibility and pay for it with a class that can exist in states you did not intend.
The Container Question — Spring's ApplicationContext vs. Koin's Module Graph
This is the comparison you came for. Spring dependency injection runs through the ApplicationContext, which detects and wires beans using reflection and annotations. Koin takes the opposite route — a declarative Kotlin DSL with runtime resolution and no code generation. The difference is not stylistic. It is a hard constraint on mobile.
Kotlin Multiplatform targets iOS and Native, and those platforms lack JVM-style reflection. An annotation-and-reflection-heavy container simply is not viable there. Koin's maintainers at Kotzilla describe it as a "pragmatic Kotlin injection framework" providing multiplatform DI across native mobile, web, and backend — precisely because the DSL resolves at runtime without leaning on reflection or generated code.
| Factor | Spring ApplicationContext | Koin | Manual constructor DI |
|---|---|---|---|
| Wiring mechanism | Reflection + annotations | Kotlin DSL, runtime | Hand-written |
| Compile-time safety | Partial (runtime errors) | Runtime resolution | Full |
| KMP / iOS-native support | No (JVM reflection) | Yes (multiplatform) | Yes |
| Startup cost | Higher (context scan) | Lower (no codegen) | Lowest |
| Best fit | Large JVM backends | KMP apps | Small apps |
The transferable lesson is in the scoping. Spring's scope model maps conceptually onto Koin's DSL almost one to one, and once you see it, Koin stops feeling foreign.
Spring's singleton scope means one instance per ApplicationContext, reused everywhere — the default lifecycle for shared services, as Marc Nuri's write-up on Spring bean scopes describes. That is Koin's single. Spring's prototype scope creates a new instance every time it is requested, for stateful or short-lived collaborators. That is Koin's factory. And Spring's web-focused request and session scopes tie lifecycles to HTTP boundaries; on mobile, the analog is a per-screen or per-flow lifetime, which Koin expresses as scoped.
Marc Nuri also flags a trap that translates directly. Injecting a prototype-scoped bean into a singleton defeats the prototype scope entirely — the singleton holds one instance for its whole life, so the "new instance per request" promise never fires. Worse, it contradicts IoC, because the dependency ends up being requested rather than injected. On mobile, this is exactly what happens when you leak a factory inside a single. You think you are getting fresh instances; you are getting one, forever.
Spring taught the industry to think in object graphs; on mobile, your job is to keep that graph small enough to reason about.
Do not forget the third column. Manual constructor wiring, with no container at all, gives you full compile-time safety and the simplest mental model available. For a small app, that is often the right answer — the r/java IoC-versus-DI discussions make the case that containers add indirection you may not need. A container earns its keep when the object graph and its lifecycles grow beyond what you can hand-assemble comfortably. The professional maintainability literature agrees: DI containers pay off at scale, where large graphs and complex lifecycles overwhelm manual wiring. The platform-specific pieces — your Networking client and Local Database layer — are exactly the dependencies you want scoped behind the container so the shared module never touches a concrete implementation.
Translating Spring's Lessons into a KMP Architecture
Here is the translation layer, as a sequence you can actually run. Each step carries a concrete reason, and none of them re-explains IoC — you already own that.
1. Define interfaces at the shared-module boundary. Depend on abstractions, not concretes. This is the Dependency Inversion Principle that Spring's model enforces — Martin Fowler's writings on DI frame it as depending on meaningful abstractions and passing collaborators in from the outside. Your shared code declares what it needs (interface UserRepository), never how that need is met.
2. Inject via constructor into ViewModels and UseCases. Same reasoning that won the injection-style argument: non-null, immutable, and testable without the container. A UseCase that takes its Repository as a constructor val can be built with a fake in one line of test code, no framework in sight.
3. Declare bindings in Koin modules, one per feature. This mirrors keeping Spring's context from ballooning into a monolith. Koin's DSL, with single and factory, encourages small, feature-focused graphs — an authModule, a paymentsModule, a profileModule — rather than one sprawling registry that every part of the app reaches into.
4. Scope platform-specific implementations behind expect/actual. Your HttpClient, local database, and secure storage differ per platform. The shared module declares the expect interface; each platform's actual supplies the implementation. This is the concrete answer to why reflection-based containers fail on iOS — you are not asking a runtime to discover implementations, you are declaring them at compile time per target. It is also the natural home for platform-specific Authentication implementations, where Apple, Google, and email flows resolve differently on each OS.
5. Resolve the graph once at a single composition root at app startup. One place owns the wiring. Nothing else touches the container. When you have a single composition root, you have a single file to read to understand how the entire app is assembled — and a single place to change when the assembly changes.
Where DI Goes Wrong — Anti-Patterns Spring Devs Learned the Hard Way
DI is not a cure-all. The Cal Poly maintainability study is careful about this: introducing DI can add configuration complexity and does not automatically improve a design that was already loosely coupled. It is a tool, and tools get misused. A peer-reviewed catalog of DI anti-patterns — "Cataloging dependency injection anti-patterns in software systems" — documented recurring failures like overuse of global singletons, service locator misuse, and excessive indirection, and showed that DI usage patterns significantly affect defect rates and evolution effort. Here are the five that hit hardest on mobile.
Service locator disguised as DI. Objects reach into a global registry to pull their dependencies instead of receiving them. It looks like DI because a container is involved, but it hides dependencies all over again and kills the testability you adopted DI to get. If your class calls getKoin.get<Repository> in its body, you have built a service locator with extra steps.
God modules. One giant Koin module that declares every binding in the app recreates Spring's monolithic-context problem. It becomes a merge-conflict magnet and a comprehension bottleneck. Split it per feature. A binding should live in the module for the feature that owns it.
Over-scoping. Leak a single that holds an Android Context tied to an Activity, and you have a memory leak that outlives the screen. This is the mobile version of Marc Nuri's singleton-holding-the-wrong-lifecycle warning. A single lives as long as the app; anything with a shorter, screen-bound lifecycle does not belong inside it.
Field injection everywhere. Nullable until wired, order-dependent, and painful in tests. Spring's own documentation discourages it in production for exactly these reasons. On KMP, where you want val immutability and compile-time guarantees, field injection is a step backward. Reserve it for test fixtures if at all.
Injecting the framework. When your domain code imports the DI library itself, you have coupled business logic to infrastructure. On Dev.to, engineer Leon Pennings argues that DI-framework overuse can "destroy encapsulation, locality, readability, evolvability" and stop developers from naming their core domain concepts. His full critique — "Dependency Injection: The Anti-Pattern That Killed Object-Oriented Design and Won" — overstates the case for effect, but the underlying warning is sound: a container that shows up in your domain layer is a container that has escaped its role.
The container should be invisible to your business logic — the moment your domain code imports the DI library, you've lost the plot.
A Pre-Ship DI Wiring Checklist for Your Next KMP App
Run this before you ship. Each item is a yes/no gate with a one-line reason. Paste it into your PR template and make it a wall your code has to clear.
- Is constructor injection your default, with field injection banned outside test fixtures? Immutability and testability depend on it.
- Do all cross-module dependencies sit behind interfaces at the shared-module boundary? That is the Dependency Inversion Principle made real.
- Is there exactly one Koin module per feature — no god module? Small graphs stay readable and merge-friendly.
- Are all platform-specific implementations (
HttpClient, local DB, secure storage) hidden behindexpect/actual? This is the KMP/iOS reflection constraint, not a preference. - Is your object graph resolved once at a single composition root? One owner of wiring, one place to read.
- Can you swap every dependency for a test double without touching production modules? This is the acid test of good DI. If you cannot, the wiring is wrong.
- Does any
singlehold an AndroidContextor other lifecycle-bound object it shouldn't? Over-scoping like this is a memory leak waiting to fire. - Does any domain-layer class import the DI library directly? That is the framework-coupling smell — fix it before it spreads.
A production-ready KMP boilerplate should ship this wiring correctly out of the box, which is the point of starting from a foundation rather than a blank file — something KMP Kit builds into its Discover, MVP, and Scale pricing tiers so you inherit a clean composition root instead of assembling one under deadline pressure.

Frequently Asked Questions
Do I need a DI framework at all for a small KMP app, or is manual constructor injection enough?
For small apps, manual constructor wiring gives strong compile-time safety, avoids startup overhead, and still achieves full testability through manual dependency passing — a point the r/java community and broader consensus keep returning to. You lose nothing on correctness. Reach for Koin when the object graph and its lifecycles grow beyond what you can hand-assemble comfortably, which is usually when features and scoped dependencies start multiplying.
Why can't I just use annotation-based DI like Spring or Dagger on iOS with KMP?
Spring's ApplicationContext relies on JVM reflection and annotation scanning to discover and wire beans. KMP's iOS and Native targets do not have JVM-style reflection, so those mechanisms have nothing to hook into. Koin's Kotlin DSL resolves at runtime without code generation or reflection, per its maintainers, which is exactly why it works across multiplatform targets where reflection-based containers cannot.
Does Koin's runtime resolution hurt app startup performance compared to compile-time DI?
Koin uses no code generation and a lightweight DSL, so its startup cost is low relative to reflection-heavy containers. The bigger lever is graph shape: keep modules small and feature-scoped rather than assembling one global graph, and resolution stays fast. A bloated single module hurts more than the resolution mechanism itself.
How is dependency injection different from the service locator pattern?
With DI, dependencies are pushed into an object from outside, typically through its constructor — the object never asks for them. With a service locator, the object pulls its own dependencies from a global registry. That pull hides the dependencies again and reintroduces the coupling and untestability DI was meant to remove, which is why the anti-pattern literature and Baeldung both treat service-locator-in-DI-clothing as a regression, not a shortcut.