Managing Large Dependency Graphs in Android
Strategies for structuring, optimizing, and debugging dependency injection graphs in large Android apps using Dagger/Hilt, covering scoping, build performance, and runtime cost.
Context
A large Android app with 200+ Gradle modules and 50+ feature teams will have thousands of injectable classes. The DI graph becomes a critical piece of infrastructure that affects build times, app startup, runtime memory, and developer productivity. Mismanaged DI graphs cause slow builds, silent runtime failures, and startup regressions that are difficult to attribute to any single change.
Related: Designing a Feature Flag and Remote Config System.
Problem
Dagger (and Hilt) generates code at compile time to wire dependencies. This is powerful but introduces scaling challenges:
- Adding a single
@Injectclass in a core module triggers recompilation of every module that depends on it - Scoping decisions (
@Singletonvs.@ActivityScopedvs. unscoped) have non-obvious memory and startup implications - Circular dependencies surface as cryptic compile errors in generated code
- Hilt's opinionated component hierarchy does not always match the app's actual scoping needs
- Build cache invalidation from DI changes cascades across the entire project
Constraints
- DI framework must support compile-time verification (no runtime reflection-based DI)
- Must work with multi-module builds (200+ modules)
- Must support incremental compilation without full graph regeneration
- Startup cost of DI initialization must stay under 100ms on mid-range devices
- Must allow independent team development without DI conflicts
Design
Component Architecture
The default Hilt component hierarchy is:
SingletonComponent
└── ActivityRetainedComponent
└── ActivityComponent
├── FragmentComponent
└── ViewComponent
└── ViewWithFragmentComponent
└── ServiceComponent
For large apps, this hierarchy is often insufficient. Teams need feature-scoped components that live between SingletonComponent and ActivityComponent.
// Custom scope for a feature that spans multiple screens
@Scope
@Retention(AnnotationRetention.RUNTIME)
annotation class CheckoutScope
// Custom component for checkout flow
@CheckoutScope
@DefineComponent(parent = SingletonComponent::class)
interface CheckoutComponent
@DefineComponent.Builder
interface CheckoutComponentBuilder {
fun build(): CheckoutComponent
}
// Entry point for the checkout feature
@EntryPoint
@InstallIn(CheckoutComponent::class)
interface CheckoutEntryPoint {
fun cartRepository(): CartRepository
fun paymentProcessor(): PaymentProcessor
}Module Organization Pattern
app/
├── :core:di # Common bindings, qualifiers, scopes
├── :core:network # @Provides for Retrofit, OkHttp
├── :core:database # @Provides for Room databases
├── :feature:feed # Feed-specific bindings
├── :feature:checkout # Checkout-specific bindings
└── :feature:profile # Profile-specific bindings
Each feature module installs bindings only in the components it needs.
// :feature:feed module
@Module
@InstallIn(SingletonComponent::class)
abstract class FeedModule {
@Binds
abstract fun bindFeedRepository(impl: FeedRepositoryImpl): FeedRepository
companion object {
@Provides
@Singleton
fun provideFeedApi(retrofit: Retrofit): FeedApi =
retrofit.create(FeedApi::class.java)
}
}
// :feature:feed module - ViewModel specific
@Module
@InstallIn(ViewModelComponent::class)
abstract class FeedViewModelModule {
@Binds
@ViewModelScoped
abstract fun bindFeedPaginator(impl: FeedPaginatorImpl): FeedPaginator
}Scoping Strategy
Scoping decisions have direct memory and performance impact.
| Scope | Lifetime | Memory Impact | Use When |
|---|---|---|---|
| Unscoped | New instance per injection | Low (GC-eligible immediately) | Stateless utilities, mappers |
@ViewModelScoped | ViewModel lifetime | Medium | Screen-specific state |
@ActivityScoped | Activity lifetime | Medium-High | Activity-wide coordinators |
@Singleton | Process lifetime | High (never GC'd) | Shared infrastructure (DB, network) |
The default should be unscoped. Only scope when you need shared mutable state or expensive initialization.
// Unscoped: new instance each time, stateless, no memory concern
class DateFormatter @Inject constructor(
private val locale: Locale
) {
fun format(timestamp: Long): String = /* ... */
}
// Singleton: expensive to create, shared across features
@Singleton
class ImageLoader @Inject constructor(
private val okHttpClient: OkHttpClient,
private val diskCache: DiskCache
) {
// ...
}
// ViewModelScoped: holds state for one screen's lifetime
@ViewModelScoped
class CartAccumulator @Inject constructor() {
private val items = mutableListOf<CartItem>()
fun add(item: CartItem) { items.add(item) }
fun total(): BigDecimal = items.sumOf { it.price }
}Build Performance Optimization
DI code generation is one of the slowest parts of a large Android build. Strategies to minimize impact:
1. Use @Binds over @Provides where possible
@Binds generates less code than @Provides. For interface-to-implementation bindings, always prefer @Binds.
// Generates less code, faster compilation
@Binds
abstract fun bindRepo(impl: RepoImpl): Repo
// Generates more code, requires a concrete method body
@Provides
fun provideRepo(impl: RepoImpl): Repo = impl // unnecessary2. Avoid @Inject on classes in high-fan-out modules
A class with @Inject in :core:models forces Dagger to process it in every module that transitively depends on :core:models. If the class does not need injection, do not annotate it.
// In :core:models - do NOT add @Inject here
data class User(val id: String, val name: String)
// In :feature:profile - inject the factory, not the data class
class UserMapper @Inject constructor() {
fun fromNetwork(dto: UserDto): User = User(dto.id, dto.name)
}3. Use @EntryPoint for leaf dependencies
When a non-Hilt class needs a dependency, use @EntryPoint instead of refactoring the entire chain to support injection.
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AnalyticsEntryPoint {
fun analyticsTracker(): AnalyticsTracker
}
// In a non-Hilt context (e.g., ContentProvider)
class MyContentProvider : ContentProvider() {
private lateinit var tracker: AnalyticsTracker
override fun onCreate(): Boolean {
val entryPoint = EntryPointAccessors.fromApplication(
context!!.applicationContext, AnalyticsEntryPoint::class.java
)
tracker = entryPoint.analyticsTracker()
return true
}
}Debugging the Graph
When the graph breaks, errors are often cryptic. Useful diagnostic techniques:
// Dump the component hierarchy at runtime (debug builds only)
@Singleton
class DiagnosticModule @Inject constructor(
private val singletonBindings: Map<Class<*>, @JvmSuppressWildcards Provider<Any>>
) {
fun logBindings() {
singletonBindings.forEach { (key, provider) ->
Log.d("DI", "Binding: ${key.simpleName} -> ${provider.get()::class.simpleName}")
}
}
}For compile-time debugging, use Dagger's --verbose flag:
// build.gradle.kts
kapt {
arguments {
arg("dagger.fullBindingGraphValidation", "WARNING")
}
}Trade-offs
| Decision | Benefit | Cost |
|---|---|---|
| Compile-time DI (Dagger) | No runtime reflection, catch errors at build | Slower builds, complex errors |
| Hilt over raw Dagger | Less boilerplate, standardized | Less flexibility in component hierarchy |
| Aggressive scoping | Fewer object allocations | Higher memory, larger startup graph |
| Minimal scoping | Lower memory | More allocations, potential re-computation |
| Multi-binding maps | Extensible plugin architecture | Hard to trace which module contributes what |
Failure Modes
See also: Building a Minimal Feature Flag Service.
- MissingBinding at compile time: a dependency is not provided in the correct component. Check that the
@Moduleis@InstallInthe right component. - Duplicate binding: two modules provide the same type in the same component. Use
@Qualifierannotations to disambiguate. - Scope mismatch: a
@Singletonscoped class depends on an@ActivityScopedclass. Dagger catches this at compile time, but the error message points to generated code. - Startup regression: adding a new
@Singletonbinding increases Application.onCreate time. Every singleton is initialized eagerly if it is part of the critical path. UseLazy<T>orProvider<T>for deferred initialization.
// Deferred initialization for non-critical singletons
@Singleton
class FeatureFlagManager @Inject constructor(
private val analyticsProvider: Provider<AnalyticsTracker>, // deferred
private val configRepo: Lazy<ConfigRepository> // deferred
) {
fun isEnabled(flag: String): Boolean {
// analyticsProvider.get() creates the instance on first access
analyticsProvider.get().track("flag_check", flag)
return configRepo.get().getFlag(flag)
}
}Scaling Considerations
- Split the
SingletonComponentinto logical sub-components for independent team ownership - Use Dagger's
@Component.Factoryor@Component.Builderfor testability - Measure DI initialization time as part of startup benchmarks
- Consider KSP over KAPT for faster code generation (2-3x improvement in processing time)
- Use Gradle build scans to identify which modules are recompiled most frequently due to DI changes
Observability
- Track singleton count per release. Unbounded growth signals scoping problems.
- Measure DI initialization time separately in startup traces
- Log
Provider<T>first-access times for lazy singletons to detect hidden startup costs - Monitor build time regression per module when DI changes are made
Key Takeaways
- Default to unscoped. Only add
@Singletonor other scopes when shared state or expensive initialization justifies it. - Use
@Bindsover@Providesfor interface bindings. It generates less code and compiles faster. - Do not add
@Injectto classes in widely-depended-upon modules unless injection is actually needed. - Use
Provider<T>andLazy<T>to defer initialization of non-critical singletons. - Treat the DI graph as infrastructure with its own performance budget and monitoring.
- Migrate from KAPT to KSP when your Dagger version supports it for significant build speed improvements.
Further Reading
- Debugging Performance Issues in Large Android Apps: A systematic approach to identifying, isolating, and fixing performance bottlenecks in large Android codebases, covering profiling strate...
- How I Profile Android Apps in Production: Techniques for collecting meaningful performance data from production Android apps without degrading user experience, covering sampling s...
- Memory Leaks in Android: Patterns I've Seen in Production: Real-world memory leak patterns from production Android apps, covering lifecycle-bound leaks, static references, listener registration, a...
Final Thoughts
Dependency injection at scale is a build system problem as much as an architecture problem. The graph that wires your app together also determines how long your builds take, how much memory your app uses at startup, and how independently your teams can work. Invest in DI infrastructure the same way you invest in modularization and CI.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.