Skip to content

Dependency Injection at Scale Deep Dive

Question ID: advanced-22
Difficulty: Senior
Tags: architecture, design-patterns, DI

Core Concept

Dependency injection decouples components by inverting controlβ€”consumers declare dependencies rather than creating them. Dagger compiles a dependency graph at build-time, guaranteeing no circular dependencies can exist at runtime.

Key Areas Covered

Dependency Graph as DAG

  • Directed Acyclic Graph: Dagger enforces no cycles (Aβ†’Bβ†’A fails at compile-time, not runtime)
  • Compile-time verification: Errors before shipping (prevents runtime surprises)
  • Example cycle: Service A @Provides B, Service B @Provides A β†’ Dagger error
  • Prevention: Detect cycles during build, force developer to refactor (introduce intermediary or break reference)

Scopes & Lifetimes

  • @Singleton: Single instance per application lifetime (caches permanently)
  • @ActivityScoped: New instance per activity (tied to activity lifecycle)
  • @FragmentScoped: New instance per fragment
  • Unscoped: Creates new instance on every injection (stateless utilities)
  • Decision: Choose scope to match object lifetime expectations

Lazy vs Provider

  • Lazy: Delays creation until .get() called; instance created once then cached
  • Provider: Fresh instance on each .get() call; useful for factories
  • Memory vs initialization: Lazy saves initialization time if object never used; Provider flexible for repeated needs

Real-World Scale Issues

  • 100+ modules β†’ Dagger build overhead: Full graph analysis takes seconds per compile
  • Solution strategies:
  • Hierarchical graphs (separate Dagger instances per module)
  • Library modules (pre-compiled dependency graphs)
  • Lazy module loading (load graph only when needed)

Hilt Auto-Wiring

  • Less boilerplate: @HiltViewModel, @AndroidEntryPoint reduce setup
  • Trade-off: Less explicit wiring makes debugging harder when app doesn't inject as expected
  • Hidden dependencies: Auto-wiring can hide implicit assumptions about availability

Real-World Patterns

Pattern: Circular Dependency Detection

// This fails at compile-time:
@Module
class ServiceModule {
  @Provides
  fun provideA(b: B) = A(b)  // A needs B

  @Provides
  fun provideB(a: A) = B(a)  // B needs A
}

// Dagger error: Cycle detected at build-time

Pattern: Scope Mismatch

// Problem: Activity-scoped dependency in singleton
@Provides
@Singleton
fun provideLongLivedService(activity: Activity) {
  // ERROR: Activity dies, singleton still holds reference β†’ memory leak
}

// Fix: Inject activity-scoped dependency into activity-scoped service
@Provides
@ActivityScoped
fun provideActivityService(activity: Activity) { ... }

Pattern: Module Hierarchy at Scale

:app (has Dagger Component)
β”œβ”€ :feature:home (has separate Component, depends on :core Component)
β”œβ”€ :feature:login (separate Component)
└─ :core (base Component, provides AppContext, Database, etc.)

Tradeoffs

Factor Tight Graph Loose Services
Build Time Longer (full analysis) Shorter (less coupling)
Runtime Errors None (caught at compile) Possible (missing bindings)
Flexibility Less (strict contracts) More (manual wiring)
Debugging Explicit (graph visible) Implicit (harder to trace)

Interview Signals

Strong answers include:

  • Understanding cycles fail at compile-time, not runtime (significant advantage)
  • Knowing when to use Lazy vs Provider (trade memory for initialization cost)
  • Aware of scope lifetime mismatches and memory leak patterns
  • Can explain build-time cost and mitigation strategies

Weak answers:

  • Treating DI as just "injecting dependencies" without understanding graph structure
  • Not knowing Dagger detects cycles early
  • Confusing Lazy and Provider use cases