Managing Large Dependency Graphs in Android

Dhruval Dhameliya·October 24, 2025·8 min read

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 @Inject class in a core module triggers recompilation of every module that depends on it
  • Scoping decisions (@Singleton vs. @ActivityScoped vs. 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.

ScopeLifetimeMemory ImpactUse When
UnscopedNew instance per injectionLow (GC-eligible immediately)Stateless utilities, mappers
@ViewModelScopedViewModel lifetimeMediumScreen-specific state
@ActivityScopedActivity lifetimeMedium-HighActivity-wide coordinators
@SingletonProcess lifetimeHigh (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 // unnecessary

2. 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

DecisionBenefitCost
Compile-time DI (Dagger)No runtime reflection, catch errors at buildSlower builds, complex errors
Hilt over raw DaggerLess boilerplate, standardizedLess flexibility in component hierarchy
Aggressive scopingFewer object allocationsHigher memory, larger startup graph
Minimal scopingLower memoryMore allocations, potential re-computation
Multi-binding mapsExtensible plugin architectureHard 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 @Module is @InstallIn the right component.
  • Duplicate binding: two modules provide the same type in the same component. Use @Qualifier annotations to disambiguate.
  • Scope mismatch: a @Singleton scoped class depends on an @ActivityScoped class. Dagger catches this at compile time, but the error message points to generated code.
  • Startup regression: adding a new @Singleton binding increases Application.onCreate time. Every singleton is initialized eagerly if it is part of the critical path. Use Lazy<T> or Provider<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 SingletonComponent into logical sub-components for independent team ownership
  • Use Dagger's @Component.Factory or @Component.Builder for 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 @Singleton or other scopes when shared state or expensive initialization justifies it.
  • Use @Binds over @Provides for interface bindings. It generates less code and compiles faster.
  • Do not add @Inject to classes in widely-depended-upon modules unless injection is actually needed.
  • Use Provider<T> and Lazy<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

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