
Mobile App Dependency Injection Crash Debugging Guide
title: "Mobile App Dependency Injection Crash Debugging Guide"
Mobile apps rely heavily on dependency injection (DI) frameworks to manage object graphs, decouple modules, and enable testable architectures. But when DI resolution fails at runtime, the result is often an instant crash — not a graceful fallback, not a logged warning, but a hard crash that kills your app before it even finishes loading. Dependency injection crash debugging is a specialized discipline that spans Android frameworks like Dagger, Hilt, and Koin, as well as iOS frameworks like Swinject. Each has its own failure modes, error messages, and debugging strategies. This guide covers the most common DI crash patterns and how to fix them before they reach production.
Why DI Frameworks Crash at Runtime
Unlike compile-time dependency resolution in languages like Rust, mobile DI frameworks often defer object creation and wiring to runtime. This means a misconfigured module, a missing @Provides annotation, or a circular dependency graph won't surface until the app tries to resolve that component — potentially deep inside a user session, not just at startup. The crash stack traces are notoriously opaque: a Dagger MissingBinding error buried under 40 frames of generated code, or a Koin NoBeanDefFoundException with no hint about which module is misconfigured.
The root causes cluster into several categories: missing or incorrectly scoped bindings, circular dependencies that exhaust the provider resolution stack, type-erased generics confusing the injector, and platform-specific lifecycle mismatches — such as a ViewModel-scoped dependency being requested after the ViewModel has been cleared. Understanding which scenario applies to your crash is the first step in effective dependency injection crash debugging.
Dagger and Hilt: Compile-Time Promise, Runtime Reality
Dagger is famous for its compile-time dependency graph validation. In theory, this means you catch every binding error before the APK is built. In practice, teams frequently encounter runtime crashes when they bypass Dagger's static checks — for instance, by injecting dependencies through abstract @Component.Builder methods that accept runtime parameters, or by using dagger.Lazy<T> and Provider<T> where the underlying binding fails only under specific runtime conditions.
The MissingBinding error is the most common: Dagger cannot find a way to provide an instance of a given type. When this happens inside a @Component that uses @BindsInstance, the crash occurs at the exact point where the component is built — typically DaggerMyComponent.builder().build(). Stack traces point to java.lang.IllegalStateException or dagger.internal.Preconditions.checkNotNull failures, but the root cause is always a wiring gap in the module graph.
@Component(modules = [NetworkModule::class])
interface AppComponent {
fun inject(app: MyApplication)
}
@Module
class NetworkModule {
@Provides
fun provideHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
// Missing: provideRetrofitService() — crash at component build time
}Hilt introduces additional complexity through its Android-specific scopes: @Singleton, @ViewModelScoped, @ActivityScoped, @FragmentScoped, and @ServiceScoped. A crash can occur when a @FragmentScoped binding is accidentally injected into a retained ViewModel that outlives the Fragment. The result is a NullPointerException when the ViewModel tries to access a dependency whose scope has been destroyed. Hilt's generated code uses double-checked locking and dagger.internal.Preconditions, so the crash message often reads as a generic NPE rather than a helpful scope mismatch diagnostic.
The fix for most Dagger and Hilt crashes starts with the annotation processor logs. Enable -Adagger.verboseDiagnostics=true in your Gradle build and examine the generated component implementation — it's verbose but shows exactly which bindings Dagger resolved and which it couldn't. The Dagger troubleshooting guide provides additional diagnostic flags that surface binding graph issues during compilation rather than runtime.
Koin: Runtime Flexibility, Runtime Fragility
Koin takes the opposite approach to Dagger: no annotation processing, no code generation, everything resolved at runtime via a lightweight DSL-based module system. This flexibility is both its strength and its weakness. A typo in a module name, a misordered module loading sequence, or a missing single {} or factory {} declaration produces a NoBeanDefFoundException that halts the app immediately — often before the first Activity finishes launching.
val repositoryModule = module {
single<UserRepository> { UserRepositoryImpl(get()) }
}
val networkModule = module {
single<ApiService> { RetrofitApiService() }
}
startKoin {
// If repositoryModule loaded first, get() call fails —
// ApiService hasn't been registered yet
modules(listOf(repositoryModule, networkModule))
}Module loading order is the silent killer in Koin projects. The framework resolves dependencies eagerly when you call startKoin { modules(listOf(moduleA, moduleB)) }. If moduleB depends on definitions from moduleA, and you've ordered them as (moduleB, moduleA), Koin crashes before the app's first Activity is created. The stack trace points to the get() call site but gives no indication of which module is missing or how to reorder them.
Another Koin-specific crash pattern involves scoped definitions tied to Android lifecycle owners. If you define a scoped { } binding within a Koin scope attached to an Activity, and a background coroutine tries to resolve it after onDestroy(), you get a ClosedScopeException. This is especially common in apps using Koin alongside Kotlin coroutines without proper scope cancellation. The fix is to use lifecycleScope in Activities and viewModelScope in ViewModels when resolving scoped dependencies, ensuring the coroutine is cancelled before the scope is closed.
scope(named("activity_scope")) {
scoped { UserSessionManager() }
}
// Safe resolution: tied to lifecycle
lifecycleScope.launch {
val session = getKoin().getScope("activity_scope").get<UserSessionManager>()
}Swinject: iOS DI Crashes and Container Threading
On iOS, Swinject is the most widely used DI framework, offering a container-based approach similar to Koin's runtime resolution. Swinject crashes fall into three main buckets: unresolved services, circular dependencies that overflow the stack, and thread-safety violations.
An unresolved service crash happens when you call container.resolve(MyService.self) without having registered MyService beforehand. Unlike Dagger, there's no compile-time check — you find out at runtime, usually during a view controller's viewDidLoad() when dependencies are first resolved. If you force-unwrap with resolve(MyService.self)!, the crash is immediate and fatal with an unexpectedly found nil message that provides zero context about what wasn't registered.
let container = Container()
container.register(NetworkClient.self) { _ in NetworkClient() }
// Missing: container.register(UserService.self) { r in UserService(client: r.resolve(NetworkClient.self)!) }
let userService = container.resolve(UserService.self)! // Fatal error: unexpectedly found nilCircular dependencies in Swinject are particularly difficult to debug because the framework doesn't detect them — it overflows the call stack. If ServiceA depends on ServiceB, and ServiceB depends on ServiceA, Swinject enters an infinite recursion loop during resolution. The stack trace shows thousands of frames alternating between the two service initializers. The fix is to use unowned references or break the cycle with a protocol-based intermediary that defers resolution.
Thread safety is the third major crash category. Swinject's default Container is not thread-safe. Resolving services from multiple DispatchQueue threads simultaneously causes data races, leading to corrupted object graphs or EXC_BAD_ACCESS crashes. The workaround is to use Swinject's SynchronizedResolver, which wraps resolution calls in a serial queue.
let syncContainer = container.synchronize()
DispatchQueue.global().async {
let service = syncContainer.resolve(MyService.self) // Thread-safe
}Circular Dependencies: The Cross-Platform Nemesis
Circular dependencies crash apps regardless of platform or framework. On Android with Dagger, a circular dependency manifests as a compilation error — Dagger's graph validation detects cycles at build time. But with Koin, Hilt (when using assisted injection or factory providers), or Swinject, cycles slip through to runtime.
The symptom is always the same: a StackOverflowError on Android or an EXC_BAD_ACCESS on iOS, with stack traces that repeat in a recognizable alternating pattern. The root cause is almost always two classes that hold references to each other through constructor injection.
The solution is to introduce an interface or protocol and use lazy injection. In Dagger and Hilt, dagger.Lazy<T> or Provider<T> breaks the cycle at compile time by deferring instantiation. In Koin, by inject() lazily resolves at first property access, avoiding the constructor-time cycle. In Swinject, manually resolving the dependency after initialization or using a closure-based factory injection achieves the same result. The key pattern across all frameworks is the same: identify which dependency in the cycle can be deferred, and make its resolution lazy.
Debugging DI Crashes with Bugspulse
Dependency injection crashes are particularly insidious because they often occur during app startup or navigation transitions — moments when the user expects instant responsiveness. Traditional crash reporting tools capture the stack trace but provide little context about the DI graph state at the time of the crash: which module was loading, which binding was mid-resolution, and what scope was active.
BugsPulse fills this gap by capturing custom breadcrumbs that record the dependency resolution path. You can log each DI resolution step — framework name, module being loaded, binding being requested, and active scope — and replay the exact sequence that triggered the failure. This transforms an opaque stack trace into a narrative of what the DI system was doing when it crashed.
// Koin + BugsPulse breadcrumb for DI crash debugging
BugsPulse.leaveBreadcrumb("DI", mapOf(
"framework" to "Koin",
"module" to "repositoryModule",
"binding" to "UserRepository",
"scope" to "default"
))For more strategies on catching failures that don't produce explicit crash reports — including DI resolution failures that manifest as silent null returns rather than hard crashes — see our guide on silent failure debugging.
Prevention: Shift Left on DI Errors
The best DI crash debug is the one you never need to do. Several practices dramatically reduce runtime DI failures before they reach production:
Prefer compile-time validation. Dagger and Hilt catch most binding errors during :app:compileDebugKotlin. Run your build with --info to see annotation processor output, and fail CI pipelines on Dagger compilation errors — never suppress them with -Adagger.fastInit or similar flags.
Enforce module ordering in Koin. Test your startKoin {} block with an automated test that resolves every top-level binding. A simple JUnit test iterating over koin.getAll<T>() for each public type catches missing bindings before they reach QA.
Audit scope lifecycles. Map every @ActivityScoped, scoped { }, or @FragmentScoped binding against its parent lifecycle and verify no downstream consumer outlives the scope. Android's StrictMode in debug builds helps catch accidental long-lived references that survive scope destruction.
Use BugsPulse for production monitoring. Even with the best prevention, edge cases slip through. Track DI-resolution error rates in production to identify crashes caused by specific device models, OS versions, or user flows that your test suite never covered.
Ready to catch DI crashes before your users do? Get started with BugsPulse today and bring full observability to your dependency injection layer across Android and iOS.