Hilt and Dagger Interview Questions

Dependency injection questions with a focus on Hilt and Dagger: why DI matters, components and scopes, modules and qualifiers, assisted injection, testing with Hilt, multi-module setups and fixing common build errors.

By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked

50 questions with answers2 easy · 10 medium · 38 hard12 marked most asked

All 50 questions

  1. What is Dependency Injection and why do we need it? What problems does it solve? Most asked
  2. What is Hilt? How does it differ from Dagger 2, and why did Google recommend it over plain Dagger? Most asked
  3. Explain all Hilt component scopes and their lifetimes. What happens if you use the wrong scope? Most asked
  4. What is the difference between @Provides and @Binds? When do you use each? Most asked
  5. How do you write unit tests and instrumented tests with Hilt? Explain TestInstallIn and @HiltAndroidTest. Most asked
  6. What are Hilt custom qualifiers? How do you inject multiple implementations of the same interface?
  7. Scenario: You need to inject different analytics implementations — Firebase for production, LogAnalytics for debug builds. How do you do this with Hilt?
  8. Scenario: How do you set up Hilt in a multi-module Android project? What are the common pitfalls?
  9. What is field injection vs constructor injection in Hilt? Which is preferred and why? Most asked
  10. What is Hilt's @EntryPoint? When do you need it and how does it work?
  11. What is Koin? How does it compare to Hilt and when would you choose it? Most asked
  12. Scenario: A ViewModel needs a dependency that changes based on user login state (authenticated vs guest). How do you handle this with Hilt?
  13. What is manual dependency injection (without a framework)? When does it make sense? Most asked
  14. What are Hilt multibindings (@IntoSet, @IntoMap)? Give a real-world use case.
  15. Scenario: You have a circular dependency in your DI graph — A depends on B and B depends on A. How do you detect and fix it?
  16. What is the service locator pattern? How does it differ from DI, and why is it considered an anti-pattern? Most asked
  17. Scenario: You're migrating from manual DI (AppContainer) to Hilt in a large existing app. What's your strategy?
  18. What is lazy injection in Hilt/Dagger? How does dagger.Lazy<T> differ from Kotlin's lazy? Most asked
  19. Scenario: You need to inject a dependency into a WorkManager Worker. How does Hilt support this?
  20. What is Assisted Injection in Hilt? When do you need it and how does it work?
  21. Scenario: Your team decides to adopt Hilt but the senior dev says "We already have clean interfaces — DI without a framework is enough." How do you respond?
  22. What is optional injection in Hilt? How do you handle nullable or optional dependencies? Most asked
  23. Scenario: Review this Hilt code — find all the problems and fix them.
  24. What is the purpose of @ApplicationContext and @ActivityContext in Hilt? Most asked
  25. Scenario: You're leading a team and a junior asks "when should I NOT use DI?" — how do you answer?
  26. How does Hilt handle ViewModel injection under the hood? What does @HiltViewModel actually generate?
  27. Scenario: Your app has a complex DI graph with 50+ modules. Hilt's build time is slow. How do you diagnose and optimise it?
  28. What is Hilt's component hierarchy? How do child components inherit from parent components?
  29. Scenario: You need to inject a dependency that's constructed from a third-party SDK that initialises asynchronously. How do you handle async initialisation in Hilt?
  30. Scenario: You want to intercept every network request to add auth headers. How do you implement this cleanly using DI and OkHttp interceptors?
  31. Scenario: You need two separate Retrofit instances — one for your own API and one for a third-party API with different base URLs, headers, and timeouts. How do you set this up with Hilt?
  32. Scenario: A colleague argues that @Inject on every class makes the codebase "tightly coupled to Hilt." Are they right, and how do you respond?
  33. What is the Hilt testing pyramid? How do you test DI bindings at each level?
  34. Scenario: Your app's UI tests are very slow because Hilt's full graph is initialised for every test. How do you speed them up?
  35. How does Hilt integrate with Jetpack Compose navigation? How do you scope a ViewModel to a NavGraph in multi-module?
  36. Scenario: You discover your app has a @Singleton dependency that holds a large in-memory cache growing unboundedly. How do you fix this with DI?
  37. Scenario: You need to provide different database configurations (in-memory for tests, WAL mode for production). How do you architect this with Hilt?
  38. What is the difference between @Singleton in Hilt vs the Singleton design pattern? What are the pitfalls of each?
  39. Scenario: You're leading a DI design review. The team has these patterns — which do you approve, which do you reject, and why?
  40. How does Hilt handle injection into non-standard Android classes like BroadcastReceiver, ContentProvider, and Service?
  41. Scenario: Your team is adopting Kotlin Multiplatform. You have Hilt in the Android app. How do you share the business logic while keeping DI working?
  42. What are the Hilt updates and improvements in 2024-25? How do they affect your DI patterns?
  43. Scenario: You're doing a DI health check on a legacy codebase. What red flags do you look for and what tools do you use?
  44. Scenario: You're architecting DI for a 10-module app from scratch. Walk through every decision you'd make.
  45. Scenario: Your Hilt build fails with "cannot be provided without an @Provides-annotated method." How do you systematically diagnose and fix this error?
  46. How does Hilt handle injection in Jetpack Compose — what is the difference between hiltViewModel() and viewModel() and when should you use each?
  47. What is the "Hilt + Compose" injection anti-pattern of passing ViewModel down the composable tree? How do you fix it?
  48. Scenario: You have a background service that needs to post updates to the UI. How do you architect this cross-component communication cleanly using DI?
  49. How do you inject environment-specific base URLs, API keys, and configuration into Hilt without hardcoding them?
  50. Scenario: Reflect on everything you know about DI. When is DI the wrong solution, and what architectural principle guides that decision?
EasyMost asked

1. What is Dependency Injection and why do we need it? What problems does it solve?

Dependency Injection is a pattern where objects receive their dependencies from outside instead of creating them internally. It solves tight coupling, poor testability, and hidden dependencies.

// WITHOUT DI — class creates its own dependencies
class UserViewModel {
    private val api = Retrofit.Builder()
        .baseUrl("https://api.example.com").build()
        .create(UserApi::class.java)
    private val db  = Room.databaseBuilder(..., AppDatabase::class.java, "app.db").build()
    private val repo = UserRepositoryImpl(api, db.userDao())
    // Problems:
    // 1. Cannot test without real network + database
    // 2. New Retrofit instance every ViewModel — memory waste
    // 3. Change API URL → edit every class that creates Retrofit
    // 4. Hidden dependencies — hard to understand what class needs
}

// WITH DI — dependencies provided from outside
class UserViewModel @Inject constructor(
    private val repo: UserRepository   // received, not created
) : ViewModel() {
    // Benefits:
    // 1. Test with FakeUserRepository — no network needed
    // 2. One shared Retrofit instance — created by Hilt @Singleton
    // 3. Change URL → edit one @Provides method in NetworkModule
    // 4. Explicit dependencies — constructor reveals what's needed
}

// Three types of DI:
// Constructor injection: class Foo @Inject constructor(val bar: Bar)
// Field injection:      @Inject lateinit var bar: Bar  (avoid if possible)
// Method injection:     @Inject fun inject(bar: Bar) (rare)

Dependency Injection is a design pattern where a class receives its dependencies from the outside rather than constructing them internally. Without DI, a class that needs a database writes val db = Room.databaseBuilder(...).build() inside itself — it now knows the concrete type, the build configuration, and the lifecycle. Change the database strategy and you touch every class that builds one. With DI, the class simply declares class UserRepo(private val db: AppDatabase) and something else is responsible for providing a correctly-configured instance. The class only needs to know its interface.

Testability is the most immediate practical benefit. When a class constructs its own dependencies you cannot substitute them in tests — you are forced to test the real database, the real network, the real everything. With constructor injection you pass a FakeUserDao directly, making tests fast, deterministic, and isolated from infrastructure. This is not a minor convenience; it is often the single change that makes a codebase testable at all. Constructor injection is preferred over field injection because the dependency list is explicit at construction time, the compiler enforces it, and there is no window where the object exists in a partially-initialised state.

Single responsibility is the deeper architectural principle DI enforces. A class that builds its own database is responsible for using the database and for knowing how to configure it — two responsibilities. DI separates construction from usage: modules or containers own the construction policy, and classes own the business logic. Lifecycle management follows naturally: the DI framework tracks whether something should be a singleton, scoped to a screen, or re-created each time. These decisions live in one place rather than scattered across every class that happens to need the dependency.

💡 Interview Tip

Lead with testability: "Without DI, UserViewModel creates its own Retrofit — I can't test it without a real network. With DI, I inject FakeUserRepository and tests run in milliseconds offline." This is the answer interviewers want, not a textbook definition.

Mark it done or save it in the question bank · Back to top

EasyMost asked

2. What is Hilt? How does it differ from Dagger 2, and why did Google recommend it over plain Dagger?

Hilt is Google's opinionated DI framework built on top of Dagger 2. It eliminates the massive boilerplate of Dagger while keeping compile-time code generation and type safety.

// Dagger 2 — powerful but verbose
// Must manually define:
@Component(modules = [NetworkModule::class, DatabaseModule::class])
interface AppComponent {
    fun inject(activity: MainActivity)
    fun inject(service: SyncService)
    fun userViewModelFactory(): UserViewModelFactory
    // Must declare every injection point manually
}
// Also need: SubComponents, Component.Builder, ViewModelFactory...
// Estimate: 50+ lines of boilerplate per app just for components

// Hilt — same power, zero component boilerplate
@HiltAndroidApp class MyApp : Application()   // Hilt generates the component
@AndroidEntryPoint class MainActivity : AppCompatActivity()  // auto-injects
@HiltViewModel class UserViewModel @Inject constructor(...)

// Hilt generates (at compile time):
// - App-level Dagger Component
// - Activity/Fragment components with proper scopes
// - ViewModelFactory for all @HiltViewModel classes
// Zero of this is written by the developer

// Under the hood: Hilt IS Dagger
// Hilt generates Dagger code from its annotations
// @HiltAndroidApp → generates Hilt_MyApp extends MyApp with full Dagger component
// All Dagger features available (Subcomponents, Multibindings, etc.)

// Why Hilt won:
// ✅ Standard — one way to do DI in Android, not 5 different patterns
// ✅ Integrated — works with ViewModel, WorkManager, Navigation
// ✅ Testing — HiltRule for instrumented tests, TestInstallIn for unit tests

Dagger 2 is a powerful but verbose DI framework. To inject a ViewModel you must manually write a @Component interface, declare @SubComponent hierarchies for Activity and Fragment scopes, write factory methods, and wire everything together in your Application class. Hundreds of lines of glue code exist purely to satisfy the framework, and every new scope requires repeating the pattern. Teams new to Dagger routinely spend days getting the first injection working before writing any real logic.

Hilt is an opinionated wrapper that generates all of that Dagger boilerplate automatically. Annotating your Application with @HiltAndroidApp and your Activities with @AndroidEntryPoint tells Hilt to generate the corresponding Dagger components behind the scenes. Critically, Hilt still generates real Dagger code — the compile-time safety that makes Dagger valuable (missing binding = build error, not runtime crash) is fully preserved. You get all the correctness guarantees with a fraction of the setup cost.

Hilt's predefined scopes — SingletonComponent, ActivityRetainedComponent, ViewModelComponent, ActivityComponent, FragmentComponent — cover the entire Android lifecycle without any custom scope declarations. Testing integration is also first-class: @HiltAndroidTest enables Hilt injection in instrumented tests, @TestInstallIn swaps production modules for test doubles, and @UninstallModules removes a module for a single test class. For any new Android-only project in 2025, Hilt is the clear default choice over raw Dagger 2.

💡 Interview Tip

"Hilt IS Dagger under the hood — it generates the boilerplate Dagger needs. You get all of Dagger's compile-time safety with none of the Component/SubComponent ceremony. That's why Google recommends it as the standard."

Mark it done or save it in the question bank · Back to top

MediumMost asked

3. Explain all Hilt component scopes and their lifetimes. What happens if you use the wrong scope?

Hilt's scope annotations control how long a dependency lives. Using too broad a scope causes memory leaks; too narrow causes wasteful re-creation of expensive objects.

// SCOPE HIERARCHY (broadest → narrowest):

// @Singleton — lives for entire Application lifetime
@InstallIn(SingletonComponent::class)
@Provides @Singleton
fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase = ...
// Created once. Use for: OkHttp, Retrofit, Room, shared repos

// @ActivityRetainedScoped — survives rotation, dies with Activity
// Same lifetime as ViewModel. Use for: per-Activity caches
@InstallIn(ActivityRetainedComponent::class)
@Provides @ActivityRetainedScoped
fun provideSession(): UserSession = UserSession()

// @ViewModelScoped — lives as long as the ViewModel
// Each ViewModel gets its OWN instance of the dependency
@InstallIn(ViewModelComponent::class)
@Binds @ViewModelScoped
abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository

// @ActivityScoped — dies on every rotation!
// Use only for non-data Activity-level state (snackbar helpers, etc.)

// @FragmentScoped — dies when Fragment is destroyed
// @ServiceScoped — lives as long as the Service

// WRONG SCOPE CONSEQUENCES:

// ❌ @Singleton holding Activity Context → MEMORY LEAK
@Provides @Singleton
fun provideManager(activity: Activity): SomeManager = SomeManager(activity)
// Singleton outlives Activity → Activity can't be GC'd

// ❌ No scope where @Singleton needed → new Retrofit per injection
@Provides  // no @Singleton!
fun provideRetrofit(): Retrofit = Retrofit.Builder().build()
// New Retrofit instance every time it's injected — 10 instances!

Hilt scopes control how many instances of a binding exist and how long they live. @Singleton produces one instance for the entire app lifetime — the right scope for Retrofit, OkHttpClient, Room database, and repositories that hold shared state. Creating these objects is expensive and they are designed to be shared, so a single instance is both correct and efficient. Applying no scope at all means Hilt creates a fresh instance on every injection, which for a Room database would be catastrophic.

@ActivityRetainedScoped is scoped to the ActivityRetainedComponent, which survives configuration changes like rotation but dies when the Activity is genuinely finished. This makes it appropriate for session state or login tokens that should persist across rotations but be cleared when the user leaves the screen. @ViewModelScoped is narrower: each ViewModel instance gets its own scoped instance. This is the correct scope for a repository that manages state specific to one screen — it lives and dies with the ViewModel, preventing accidental sharing between screens.

@ActivityScoped is the easy trap: it looks like it might survive rotation because it is tied to the Activity, but the Activity is recreated on rotation, so @ActivityScoped instances are destroyed and recreated too. Storing data-layer dependencies with @ActivityScoped means losing state on rotation. The general rule is: too-broad scope leaks memory (an Activity reference in a Singleton), no scope wastes CPU, and wrong scope loses state. Choosing the narrowest scope that satisfies the sharing requirement is always the right approach.

💡 Interview Tip

The memory leak question is a favourite: "@Singleton holding an Activity Context — what's wrong?" The Singleton lives for the app's lifetime but holds a reference to the Activity, which should be destroyed. GC can't collect it → memory leak. Fix: use @ApplicationContext in @Singleton deps.

Mark it done or save it in the question bank · Back to top

MediumMost asked

4. What is the difference between @Provides and @Binds? When do you use each?

@Provides executes code to build a dependency. @Binds declares which implementation satisfies an interface — zero code, just a mapping. @Binds is more efficient and preferred for interface bindings.

// @Provides — for third-party or complex construction
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(AuthInterceptor())
        .connectTimeout(30, TimeUnit.SECONDS)
        .build()  // needs code to construct — @Provides required

    @Provides @Singleton
    fun provideApi(client: OkHttpClient): UserApi =
        Retrofit.Builder().client(client).build().create(UserApi::class.java)
}

// @Binds — for your own classes with @Inject constructor
// Module MUST be abstract class, method MUST be abstract
// No body — just declares the mapping
@Module @InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds @Singleton
    abstract fun bindUserRepo(impl: UserRepositoryImpl): UserRepository

    @Binds
    abstract fun bindAnalytics(impl: FirebaseAnalytics): AnalyticsTracker
}

// Mixing both in the same module — companion object pattern
@Module @InstallIn(SingletonComponent::class)
abstract class AppModule {
    @Binds @Singleton
    abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository

    companion object {
        @Provides @Singleton  // @Provides inside companion of abstract class
        fun provideDb(@ApplicationContext ctx: Context): AppDatabase =
            Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()
    }
}

// Efficiency: @Binds = direct delegation, @Provides = creates a Provider class
// @Binds generates LESS code — prefer it whenever possible

@Provides is for bindings that require running code. When you build a Retrofit instance — configuring the base URL, adding a Moshi converter, attaching an OkHttp client — that construction logic lives inside a @Provides function. The function body executes when Hilt needs to supply the dependency. Any third-party class you do not own falls into this category: you cannot add @Inject to Retrofit's constructor, so @Provides is your only option.

@Binds is a pure declaration: "when something asks for this interface, give it this implementation." It generates less code because there is no function body — Dagger knows the implementation already has an @Inject constructor and can construct it directly. @Binds requires an abstract function in an abstract class (or interface). The function signature is the binding: the parameter type is the implementation, the return type is the interface. No code runs; Dagger resolves the mapping at compile time.

Mixing both in one module is straightforward with a companion object pattern: the enclosing class is abstract (satisfying @Binds), and a companion object annotated with @Module holds the concrete @Provides functions. The decision rule is simple: if you own the class and can annotate its constructor with @Inject, use @Binds — it is more efficient and idiomatic. If you do not own the class or construction requires configuration logic, use @Provides.

💡 Interview Tip

The reason @Binds is preferred is efficiency: Dagger generates a simple delegation for @Binds but a full Provider class for @Provides. Less generated code = faster builds and smaller APK. Use @Provides only when you actually need to execute construction code.

Mark it done or save it in the question bank · Back to top

MediumMost asked

5. How do you write unit tests and instrumented tests with Hilt? Explain TestInstallIn and @HiltAndroidTest.

Hilt provides two testing strategies: TestInstallIn for replacing modules in unit/instrumented tests, and HiltAndroidTest for full instrumented test flows. Both allow swapping real deps for fakes.

// UNIT TESTS — no Hilt, constructor injection of fakes
class UserViewModelTest {
    private val fakeRepo = FakeUserRepository()
    private val vm = UserViewModel(fakeRepo)  // manual injection
    // No Hilt needed — just pass the fake directly
}

// INSTRUMENTED TESTS — @HiltAndroidTest
// testImplementation("com.google.dagger:hilt-android-testing")

// Replace production module with test module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces   = [RepositoryModule::class]  // replaces production module
)
@Module
abstract class FakeRepositoryModule {
    @Binds @Singleton
    abstract fun bindRepo(fake: FakeUserRepository): UserRepository
}

// Instrumented test class
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class UserScreenTest {
    @get:Rule val hiltRule = HiltAndroidRule(this)
    @get:Rule val composeRule = createAndroidComposeRule<HiltTestActivity>()

    @Inject lateinit var fakeRepo: FakeUserRepository  // inject the fake

    @Before fun setUp() { hiltRule.inject() }

    @Test fun showsUserName() {
        fakeRepo.setUser(User("1", "Alice"))
        composeRule.onNodeWithText("Alice").assertIsDisplayed()
    }
}

// Per-test module replacement — @UninstallModules
@HiltAndroidTest
@UninstallModules(NetworkModule::class)  // remove production module for this test
class SpecificTest { /* define replacement module inside */ }

The most important insight about testing with Hilt is that most tests do not need Hilt at all. Unit tests for a ViewModel should construct it directly: val vm = MyViewModel(FakeRepo(), FakeLogger()). Constructor injection makes this trivial — no framework involvement, no annotation processing, no test configuration. Fast, readable, and completely reliable. Reaching for Hilt in unit tests is a sign that your class might have too many dependencies or is not designed for testability.

Instrumented tests that need a real component graph use Hilt's testing APIs. @HiltAndroidTest on the test class enables injection, and a HiltAndroidRule field triggers injection before each test. To replace a production module globally — across all tests in the test APK — annotate a test module with @TestInstallIn(components = [SingletonComponent::class], replaces = [NetworkModule::class]). This swaps the real network for a fake network everywhere without touching production code. For one-off overrides in a single test class, @UninstallModules(NetworkModule::class) plus a local @Module in the test class achieves targeted replacement.

HiltTestActivity solves a specific problem in Compose UI tests: your production Activity is annotated with @AndroidEntryPoint, but instrumented tests need a controlled Activity they own. Hilt provides a built-in HiltTestActivity for this purpose. Combined with createAndroidComposeRule<HiltTestActivity>(), you get a fully Hilt-injected Compose environment with the flexibility to swap dependencies per test. The layered approach — unit tests with fakes, integration tests with @TestInstallIn, UI tests with HiltTestActivity — gives comprehensive coverage without over-engineering any single layer.

💡 Interview Tip

The key insight: for unit tests, don't use Hilt at all — just pass fakes to the constructor. Hilt in unit tests adds complexity for no benefit. Only use @HiltAndroidTest for instrumented tests that need the full Android + DI stack. This separation keeps unit tests fast and simple.

Mark it done or save it in the question bank · Back to top

Hard

6. What are Hilt custom qualifiers? How do you inject multiple implementations of the same interface?

When two @Provides methods return the same type, Hilt can't decide which to inject. Custom @Qualifier annotations distinguish between bindings — type-safe unlike @Named strings.

// Problem: two CoroutineDispatchers — Hilt can't differentiate
@Provides fun provideIo(): CoroutineDispatcher = Dispatchers.IO
@Provides fun provideMain(): CoroutineDispatcher = Dispatchers.Main
// Build error: multiple bindings for CoroutineDispatcher

// Solution: Custom @Qualifier annotations
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class MainDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultDispatcher

@Module @InstallIn(SingletonComponent::class)
object DispatcherModule {
    @Provides @Singleton @IoDispatcher
    fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

    @Provides @Singleton @MainDispatcher
    fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main

    @Provides @Singleton @DefaultDispatcher
    fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}

// Inject with qualifier
class UserRepository @Inject constructor(
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
    private val api: UserApi
) {
    suspend fun getUser(id: String) = withContext(ioDispatcher) { api.getUser(id) }
}

// Test module — swap IoDispatcher for TestDispatcher
@TestInstallIn(components = [SingletonComponent::class], replaces = [DispatcherModule::class])
@Module
object TestDispatcherModule {
    @Provides @IoDispatcher
    fun provideTestIo(): CoroutineDispatcher = StandardTestDispatcher()
}

Qualifiers solve the problem of providing multiple bindings for the same type. If you have two OkHttpClient instances — one with authentication interceptors for your API, one plain for image loading — Hilt cannot distinguish them by type alone. A custom qualifier annotation like @Retention(AnnotationRetention.BINARY) @Qualifier annotation class AuthClient lets you tag specific bindings and injection sites. The retention must be BINARY so the annotation is preserved in the compiled bytecode where Dagger can read it.

Coroutine dispatcher injection is the canonical real-world qualifier pattern. Instead of hardcoding Dispatchers.IO inside every repository, you inject an @IoDispatcher CoroutineDispatcher. The module provides Dispatchers.IO in production. In tests, a @TestInstallIn module provides StandardTestDispatcher instead. Every class that uses the injected dispatcher is now automatically test-controlled — you advance virtual time, verify suspensions, and assert ordering without any per-class test setup. This pattern eliminates an entire class of flaky async tests.

The alternative to custom qualifiers is @Named("io_dispatcher"), a built-in string qualifier. It works but introduces stringly-typed dependencies: a typo — "I0" instead of "IO" — is a runtime crash rather than a compile error. Custom qualifier annotations provide IDE completion, rename refactoring, and compile-time verification at the cost of a few extra lines defining the annotation. For any qualifier used in more than one place, the annotation approach is clearly worth it. Teams should define their qualifier annotations in a shared :core:di module so all features can reference them consistently.

💡 Interview Tip

Injecting dispatchers with @Qualifier is a production best practice that enables deterministic coroutine tests. "@IoDispatcher in production = Dispatchers.IO; in tests = StandardTestDispatcher(). Now every suspend function in every class is test-controllable without changing any production code."

Mark it done or save it in the question bank · Back to top

Hard

7. Scenario: You need to inject different analytics implementations — Firebase for production, LogAnalytics for debug builds. How do you do this with Hilt?

Build-variant-specific DI uses Gradle source sets combined with Hilt modules — each variant gets its own module binding the right implementation. Zero if/else in production code.

// Interface in main source set
interface AnalyticsTracker {
    fun track(event: String, params: Map<String, Any> = emptyMap())
    fun setUserId(id: String)
}

// src/release/java/AnalyticsModule.kt — production
class FirebaseAnalyticsTracker @Inject constructor() : AnalyticsTracker {
    override fun track(event: String, params: Map<String, Any>) {
        Firebase.analytics.logEvent(event, bundleOf(*params.entries.map { it.key to it.value }.toTypedArray()))
    }
    override fun setUserId(id: String) { Firebase.analytics.setUserId(id) }
}

@Module @InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
    @Binds @Singleton
    abstract fun bindAnalytics(impl: FirebaseAnalyticsTracker): AnalyticsTracker
}

// src/debug/java/AnalyticsModule.kt — SAME class name, different source set!
class LogAnalyticsTracker @Inject constructor() : AnalyticsTracker {
    override fun track(event: String, params: Map<String, Any>) {
        Log.d("Analytics", "Event: $event | $params")  // just logs
    }
    override fun setUserId(id: String) { Log.d("Analytics", "User: $id") }
}

@Module @InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {  // same name, picked by Gradle per build variant
    @Binds @Singleton
    abstract fun bindAnalytics(impl: LogAnalyticsTracker): AnalyticsTracker
}

// ViewModel — zero knowledge of debug vs release
class HomeViewModel @Inject constructor(
    private val analytics: AnalyticsTracker   // Firebase in prod, Log in debug
) : ViewModel()

The source-set DI pattern lets you provide completely different implementations per build variant without a single if (BuildConfig.DEBUG) check in your business logic. You create a Hilt module at src/debug/java/.../AnalyticsModule.kt and another at src/release/java/.../AnalyticsModule.kt. Both modules bind AnalyticsTracker, but the debug one provides a LoggingAnalyticsTracker that prints to Logcat, while the release one provides the real Firebase-backed tracker. Gradle automatically selects the correct source set when building each variant.

The ViewModel or use case simply declares @Inject constructor(private val tracker: AnalyticsTracker). It has no knowledge of whether it is running in debug or release. No conditional logic, no null checks, no BuildConfig references anywhere in business code. This is not just cleaner — it guarantees the production code path is never accidentally activated in debug builds and vice versa. The separation is enforced by the build system, not by runtime checks that could be typo'd or accidentally removed.

The same pattern extends beyond debug/release. Product flavors — free, pro, enterprise — can each have their own source set providing different implementations of feature interfaces. A ProSubscriptionManager in the pro flavor and a FreeSubscriptionManager in the free flavor are wired through the same injection point with zero shared conditional logic. Network interceptors for different environments, crash reporters that are no-ops in tests, feature flag providers that read from different backends — all follow the same pattern. It is one of the most underused Android build-system capabilities, and Hilt's module discovery makes it seamless.

💡 Interview Tip

The key: same interface name, same Hilt module class name, DIFFERENT source sets. Gradle's source set resolution picks src/debug or src/release automatically. No if (BuildConfig.DEBUG) checks anywhere in business code — clean, maintainable separation.

Mark it done or save it in the question bank · Back to top

Hard

8. Scenario: How do you set up Hilt in a multi-module Android project? What are the common pitfalls?

Hilt in multi-module requires each module to declare its own @Module classes, and only :app needs @HiltAndroidApp. Cross-module dependencies are wired through standard @InstallIn modules.

// :core:network/build.gradle.kts
dependencies {
    implementation("com.google.dagger:hilt-android:2.51")
    ksp("com.google.dagger:hilt-compiler:2.51")
}

// :core:network — defines NetworkModule (installed in Singleton)
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder().build()
}

// :feature:home — defines its own module, uses network
@Module @InstallIn(SingletonComponent::class)
abstract class HomeModule {
    @Binds @Singleton
    abstract fun bindHomeRepo(impl: HomeRepositoryImpl): HomeRepository
}

// :app — only needs @HiltAndroidApp and @AndroidEntryPoint
@HiltAndroidApp class MyApp : Application()
// Hilt auto-discovers ALL @Module @InstallIn classes across ALL modules
// No manual registration — just having them on the classpath is enough

// ❌ PITFALL 1: @HiltAndroidApp in library module
// Only :app should have @HiltAndroidApp
// Library modules use hilt-android without the full app plugin

// ❌ PITFALL 2: Missing hilt plugin in feature module build.gradle
// plugins { id("dagger.hilt.android.plugin") }  — needed in every module using @Inject

// ❌ PITFALL 3: Using KAPT instead of KSP
// kapt("com.google.dagger:hilt-compiler") → slow
// ksp("com.google.dagger:hilt-compiler")  → 30-50% faster

// ❌ PITFALL 4: @AndroidEntryPoint in library modules for non-Activity/Fragment
// Only Activity, Fragment, Service, BroadcastReceiver, ContentProvider need it

Hilt's module discovery is one of its biggest advantages in multi-module projects. Any @Module annotated class in any Gradle module on the classpath is automatically discovered and installed by Hilt — you do not need to manually register modules or wire subcomponents. A :feature:profile module can declare its own @Module that provides a ProfileRepository and Hilt will include it in the component graph without any changes to :app. This lets feature teams work independently without coordinating through a central DI configuration file.

@HiltAndroidApp belongs only in the :app module's Application class — it is the root that triggers component generation. Library and feature modules should never declare @HiltAndroidApp. Each feature module applies the com.google.dagger.hilt.android Gradle plugin, adds the Hilt dependency, and annotates its modules and ViewModels normally. The plugin is required in every module that uses @Inject, @HiltViewModel, or any Hilt annotation — forgetting it in a module produces cryptic build errors about missing generated code.

Build performance is a real concern in large multi-module Hilt projects. The migration from KAPT to KSP for Hilt annotation processing is the single most impactful build optimization available — clean build times typically drop 30–50% because KSP processes annotations in parallel with compilation rather than as a separate pre-compilation phase. Enable incremental KSP with ksp.incremental=true in gradle.properties so that changed files do not trigger reprocessing of the entire module. Apply KSP consistently across all modules — mixing KAPT and KSP in the same project negates most of the gains.

💡 Interview Tip

The auto-discovery mechanism is key: you don't register modules anywhere — Hilt finds all @Module @InstallIn classes across your entire classpath at compile time. This means :feature:home's HomeModule is automatically included in :app's Dagger component without any explicit registration.

Mark it done or save it in the question bank · Back to top

MediumMost asked

9. What is field injection vs constructor injection in Hilt? Which is preferred and why?

Constructor injection is always preferred — it makes dependencies explicit, enables immutability, and works with null-safety. Field injection is a necessary workaround for classes the framework instantiates.

// Constructor injection — PREFERRED
class UserRepository @Inject constructor(  // @Inject on constructor
    private val api: UserApi,               // immutable — val, not var
    private val dao: UserDao                // cannot be null — type-safe
)
// Benefits:
// - Dependencies visible from class signature
// - Class is always fully initialised
// - Easy to test: just pass fakes to constructor
// - Immutable: val, not var

// Field injection — NECESSARY for framework classes
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var analytics: AnalyticsTracker  // Android creates Activity
    // Android creates Activity via reflection — no constructor control
    // Hilt injects fields after super.onCreate()
}

// ❌ Avoid field injection in non-framework classes
class UserRepository {
    @Inject lateinit var api: UserApi   // bad! lateinit = nullable risk
    // Repository can be used before injection — NullPointerException
    // Testing: must manually set fields, not pass to constructor
}

// When framework creates the class (must use field injection):
// Activity, Fragment, Service, BroadcastReceiver, ContentProvider

// When YOU create the class (use constructor injection):
// Repository, ViewModel, UseCase, Mapper, Validator — everything else

// Method injection — rarely used
class MyClass {
    @Inject fun injectDependencies(api: UserApi) { /* ... */ }
}

Constructor injection is the gold standard: all dependencies are declared as constructor parameters, are val (immutable after construction), and the object is fully usable the moment it is created. There is no window where the object exists in a half-initialised state. The compiler enforces that all dependencies are provided — missing a binding is a build error. Your own classes — repositories, use cases, data sources, helpers — should always use constructor injection.

Field injection exists because Android creates certain classes via reflection: Activity, Fragment, Service, BroadcastReceiver. You cannot intercept their constructors, so Hilt cannot inject via constructor. Instead, @AndroidEntryPoint instructs Hilt to inject annotated @Inject fields immediately after the framework calls the constructor. The risk is the temporal gap: if any code runs between object creation and Hilt's injection call — for example in a field initializer or early callback — the injected fields are still null despite being declared lateinit var, producing an UninitializedPropertyAccessException.

Testing surfaces the quality gap clearly. A class with constructor injection can be tested with val repo = MyRepo(FakeDataSource(), FakeLogger()) — one line, no framework. A class with field injection must be instantiated by the framework or a test rule, and test fakes must be set field-by-field after construction. The boilerplate is manageable but it signals that the class is more tightly coupled to the Android framework than it needs to be. The practical guideline: use @AndroidEntryPoint and field injection only where the framework mandates it, and push as much logic as possible into injected collaborators that use constructor injection.

💡 Interview Tip

Rule: "If Android creates it, you must use field injection (@AndroidEntryPoint). If you create it, always use constructor injection." Activity, Fragment, Service → field injection. Repository, ViewModel, UseCase → constructor injection. This distinction shows you understand Hilt, not just copy-paste it.

Mark it done or save it in the question bank · Back to top

Hard

10. What is Hilt's @EntryPoint? When do you need it and how does it work?

@EntryPoint lets you inject dependencies into classes that Hilt doesn't support directly — like custom View classes, ContentProviders, or third-party framework components.

// Problem: Custom View — Hilt doesn't support @AndroidEntryPoint on View
class UserAvatarView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
    // Can't use @AndroidEntryPoint — Android creates Views
    // Can't use constructor injection — View has fixed constructors
}

// Solution: @EntryPoint — create a custom entry point
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ImageLoaderEntryPoint {
    fun imageLoader(): ImageLoader   // declare what you need
}

class UserAvatarView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
    private val imageLoader: ImageLoader by lazy {
        EntryPointAccessors
            .fromApplication(context.applicationContext, ImageLoaderEntryPoint::class.java)
            .imageLoader()
    }
}

// Another use case: ContentProvider
class UserContentProvider : ContentProvider() {
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface UserProviderEntryPoint {
        fun userRepository(): UserRepository
    }

    private val userRepo: UserRepository by lazy {
        EntryPointAccessors
            .fromApplication(context!!.applicationContext, UserProviderEntryPoint::class.java)
            .userRepository()
    }
}

// When to use @EntryPoint:
// - Custom Views that need Hilt-managed dependencies
// - ContentProvider (created before Application.onCreate)
// - Third-party framework classes you can't annotate
// - Non-Android classes that need Hilt deps at runtime

@EntryPoint provides a way to access Hilt-managed dependencies from classes that Hilt cannot inject automatically. @AndroidEntryPoint covers Activities, Fragments, Views (with @WithFragmentBindings), Services, and BroadcastReceivers. Everything outside that list — custom Views created programmatically, ContentProviders initialised before Application, WorkManager workers (pre-@HiltWorker), or any third-party framework class — needs an entry point. You define an interface annotated with @EntryPoint and @InstallIn(SingletonComponent::class) declaring the dependencies you need, then retrieve them via EntryPointAccessors.

The access pattern inside a custom View is: EntryPointAccessors.fromApplication(context.applicationContext, MyViewEntryPoint::class.java).myDependency(). This call is always lazy — it retrieves from an already-initialised component rather than constructing anything. The Hilt component exists on the Application object, so as long as you do not call this before Application.onCreate() has run, the dependency graph is available. Custom Views are the most common use case because they are inflated from XML, bypassing constructor injection entirely.

ContentProviders are the edge case that catches teams off guard. Android may initialise a ContentProvider before Application.onCreate() runs, which means the Hilt component does not yet exist when the ContentProvider constructor fires. The solution is to defer dependency access to the first actual query rather than the constructor: use Kotlin's by lazy { EntryPointAccessors.fromApplication(...) } to ensure the component is only accessed when the provider actually serves a request, by which point Application has finished initialising. This lazy pattern is the safe default for all @EntryPoint usage.

💡 Interview Tip

@EntryPoint is the "escape hatch" for Hilt — for cases where @AndroidEntryPoint doesn't apply. The most common real-world use: a custom View that needs to load images with an ImageLoader managed by Hilt. Knowing this exists (and when to use it) signals advanced Hilt knowledge.

Mark it done or save it in the question bank · Back to top

MediumMost asked

11. What is Koin? How does it compare to Hilt and when would you choose it?

Koin is a lightweight DI framework using a Kotlin DSL. It's simpler to set up and multiplatform-ready, but catches DI errors at runtime instead of compile time.

// Koin — runtime DSL-based DI
val networkModule = module {
    single { OkHttpClient.Builder().build() }
    single { Retrofit.Builder().client(get()).build() }
    single { get<Retrofit>().create(UserApi::class.java) }
}
val repoModule = module {
    single<UserRepository> { UserRepositoryImpl(get(), get()) }
    viewModel { UserViewModel(get()) }
}

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin { androidContext(this@MyApp); modules(networkModule, repoModule) }
    }
}

// Comparison:
//                  Hilt              Koin
// Error detection  Compile-time  →  Runtime (crash on first injection)
// Setup time       Hours         →  Minutes
// Code generation  Yes (KSP)     →  No (service locator)
// Multiplatform    Android-only  →  KMP ready
// Startup cost     None          →  Small (graph construction)
// Learning curve   Steeper       →  Gentle

// Choose Hilt when:
// - Android-only project, large team, production app
// - Safety over speed of setup
// - Need fine-grained scope control

// Choose Koin when:
// - Kotlin Multiplatform project (Koin supports iOS, Desktop)
// - Small project / prototype
// - Team prefers simpler, less ceremonial DI

Koin and Hilt solve the same problem with fundamentally different approaches. Koin is a service locator written in pure Kotlin: you define modules in a DSL, call startKoin at startup, and retrieve dependencies with val repo: UserRepo by inject(). There is no annotation processing, no code generation, and no build-time verification. Setup is fast and the learning curve is gentle. Hilt generates Dagger components at compile time, catching missing bindings, scope mismatches, and type errors during the build rather than at runtime. The generated code is zero-overhead — no reflection, no map lookups, just direct constructor calls in the generated factory.

Koin's critical risk is its failure mode: a missing module or forgotten binding causes a NoBeanDefFoundException crash the first time that code path runs in production. This may not be covered by your test suite if the path is rarely exercised. Hilt's failure mode is a build error — the app cannot even compile if a binding is missing. For large teams and complex graphs, discovering dependency issues in CI rather than in a production crash report is a meaningful operational difference. Hilt also integrates with Android's SavedStateHandle, Compose navigation, and the full testing toolchain through first-party libraries.

The decision in 2025 is largely determined by your architecture. If you are building an Android-only application, Hilt is the default recommendation: better tooling, stronger guarantees, official Google support. If you are building a Kotlin Multiplatform project where business logic lives in shared Kotlin modules that must run on iOS, server, or desktop, Hilt is not an option — it depends on Android's class-loading and the Android Gradle plugin. Koin supports KMP natively and is the pragmatic choice for shared modules. Some teams combine both: Koin in the shared KMP module, Hilt in the Android-specific :app layer.

💡 Interview Tip

"Hilt fails at build time if I forget a binding. Koin fails at runtime — in front of users. For a production app with a team and CI, build-time safety wins every time. I'd only choose Koin for a KMP project where Hilt's Android dependency doesn't work."

Mark it done or save it in the question bank · Back to top

Hard

12. Scenario: A ViewModel needs a dependency that changes based on user login state (authenticated vs guest). How do you handle this with Hilt?

Dynamic dependencies based on runtime state shouldn't be injected at construction time — inject a factory or a repository that encapsulates the state logic. DI is for static wiring, not runtime decisions.

// ❌ WRONG — trying to inject auth-state-dependent dep at construction
@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val repo: UserRepository  // which repo? depends on login state!
)

// ✅ SOLUTION 1: Inject a factory that creates the right impl
interface UserRepositoryFactory {
    fun create(isAuthenticated: Boolean): UserRepository
}

class UserRepositoryFactoryImpl @Inject constructor(
    private val authRepo:  AuthenticatedUserRepository,
    private val guestRepo: GuestUserRepository
) : UserRepositoryFactory {
    override fun create(isAuthenticated: Boolean) =
        if (isAuthenticated) authRepo else guestRepo
}

@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val session:     SessionManager,
    private val repoFactory: UserRepositoryFactory
) : ViewModel() {
    private val repo: UserRepository by lazy {
        repoFactory.create(session.isLoggedIn())
    }
}

// ✅ SOLUTION 2: Single repository that handles both states internally
class SmartUserRepository @Inject constructor(
    private val session: SessionManager,
    private val apiService: UserApiService
) : UserRepository {
    override suspend fun getProfile(): UserProfile =
        if (session.isLoggedIn())
            apiService.getAuthenticatedProfile(session.getToken()!!)
        else
            UserProfile.guest()  // default guest profile
}

Hilt's dependency graph is built at compile time and initialised once at app startup — it cannot make runtime decisions like "inject the authenticated repository if the user is logged in, otherwise inject the anonymous one." Attempting to put if (userSession.isLoggedIn) provide(AuthRepo()) else provide(AnonRepo()) inside a @Provides function violates the static nature of the graph and is an anti-pattern. The DI graph is for structural wiring; runtime branching belongs inside the classes themselves.

The factory pattern is the clean solution for truly distinct runtime implementations. Define a RepositoryFactory that is injected and exposes a create(sessionState: SessionState): DataRepository method. The factory is wired at compile time; the specific instance it produces is determined at runtime based on whatever state it observes. For simpler cases, a single "smart" implementation is cleaner: SmartUserRepository is injected with both a SessionManager and a RemoteDataSource, and its internal methods check session state to decide what to do. One binding, one class, runtime behaviour encapsulated internally.

The key insight is: inject the state observer, not the result of evaluating state. Inject SessionManager — an object that observes auth state — not the currently-logged-in user. Inject FeatureFlagProvider — an object that reads flags — not the current flag value. When the state changes, the injected object reports the change and the class reacts. This pattern keeps the DI graph static and correct while giving classes all the runtime information they need. It also makes the dependency explicit: the class declares it depends on session state, which is visible in its constructor signature.

💡 Interview Tip

The key insight: "DI is for wiring static structure, not runtime decisions." The DI graph is built at compile time — you can't have a Hilt binding that says 'give me AuthRepo if logged in, GuestRepo if not.' Instead, inject the SessionManager and make the decision inside the class.

Mark it done or save it in the question bank · Back to top

MediumMost asked

13. What is manual dependency injection (without a framework)? When does it make sense?

Manual DI is wiring dependencies yourself using a container or factory — no annotation processing or framework. It's appropriate for small projects, libraries, or KMP modules where Hilt's Android dependency is a problem.

// Manual DI — App Container pattern
class AppContainer(private val context: Context) {
    // Build dependency graph manually
    private val okHttp: OkHttpClient by lazy {
        OkHttpClient.Builder().build()
    }
    private val retrofit: Retrofit by lazy {
        Retrofit.Builder().client(okHttp).build()
    }
    private val userApi: UserApi by lazy {
        retrofit.create(UserApi::class.java)
    }
    private val db: AppDatabase by lazy {
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
    }
    val userRepository: UserRepository by lazy {
        UserRepositoryImpl(userApi, db.userDao())
    }
}

class MyApp : Application() {
    val container by lazy { AppContainer(this) }
}

// Access in Activity
class MainActivity : AppCompatActivity() {
    private val repo by lazy { (application as MyApp).container.userRepository }
    private val vm: UserViewModel by viewModels { UserViewModelFactory(repo) }
}

// When manual DI makes sense:
// ✅ Small app (1-2 dev, < 20 classes) — Hilt setup overhead not worth it
// ✅ Pure Kotlin library module — no Android dependency
// ✅ KMP shared module — Hilt is Android-only
// ✅ Learning DI concepts before frameworks
// ❌ Large production app, team > 3 — Hilt pays back quickly

Manual DI is exactly what it sounds like: you build the object graph yourself in code, with no annotation processing and no framework. An AppContainer class in your Application holds singleton references and constructs them in the right order. Every class still receives its dependencies through its constructor — the DI principle is preserved — but you are the one calling the constructors and managing lifetimes. This is how Google's Guide to App Architecture teaches DI before introducing Hilt, and it is a valid architecture for small-to-medium projects.

The by lazy pattern is essential in a manual container: val retrofit: Retrofit by lazy { Retrofit.Builder().baseUrl(BASE_URL).build() } defers expensive construction until first access and caches the result. This means app startup is not slowed by eagerly building every dependency, and objects that are never needed in a session are never created. The container acts as the scope manager: a reference on Application is a singleton, a reference created inside an Activity and released in onDestroy is Activity-scoped. You implement scope by controlling when references are created and when they are nulled.

Manual DI shines in contexts where frameworks are unavailable: KMP shared modules that must compile for iOS, pure Kotlin libraries, or small utility apps where Hilt's build-time cost exceeds its benefit. The limitations become painful as the graph grows: there is no compile-time validation — a missing binding is a null reference at runtime, not a build error. Scope management is entirely manual and easy to get wrong. Each new dependency must be threaded through every intermediate class. At around 10–15 injectable classes the maintenance overhead typically justifies migrating to Hilt.

💡 Interview Tip

Google's official Android documentation teaches manual DI first, then Hilt. Understanding manual DI shows you understand the CONCEPTS (object graphs, scope, lifetime) not just the annotations. "I understand what Hilt generates under the hood because I've done it manually."

Mark it done or save it in the question bank · Back to top

Hard

14. What are Hilt multibindings (@IntoSet, @IntoMap)? Give a real-world use case.

Multibindings let multiple modules contribute to a single collection — a Set or Map — without any module knowing about the others. Perfect for plugin architectures and interceptor chains.

// @IntoSet — contribute to a Set<T>
// Use case: OkHttp interceptors contributed by separate modules

// :core:auth — contributes auth interceptor
@Module @InstallIn(SingletonComponent::class)
object AuthModule {
    @Provides @IntoSet
    fun provideAuthInterceptor(session: SessionManager): Interceptor =
        AuthInterceptor(session)
}

// :core:logging — contributes logging interceptor
@Module @InstallIn(SingletonComponent::class)
object LoggingModule {
    @Provides @IntoSet
    fun provideLoggingInterceptor(): Interceptor = HttpLoggingInterceptor()
}

// :core:network — receives the complete Set
@Provides @Singleton
fun provideOkHttp(interceptors: Set<@JvmSuppressWildcards Interceptor>): OkHttpClient {
    val builder = OkHttpClient.Builder()
    interceptors.forEach { builder.addInterceptor(it) }
    return builder.build()
}
// Adding a new interceptor: add @IntoSet in its module — zero other changes!

// @IntoMap — contribute to a Map<Key, T>
// Use case: ViewModel factory per route key
@Module @InstallIn(ViewModelComponent::class)
object ViewModelModule {
    @Provides @IntoMap
    @StringKey("UserViewModel")
    fun provideUserVm(vm: UserViewModel): ViewModel = vm

    @Provides @IntoMap
    @StringKey("HomeViewModel")
    fun provideHomeVm(vm: HomeViewModel): ViewModel = vm
}

Multibindings allow multiple independent modules to contribute elements to a single Set or Map without any of them knowing about each other. With @IntoSet, each contributor annotates a @Provides or @Binds method and Hilt aggregates all contributions into a Set<T> that can be injected anywhere. The collector — often a class in the :core:network or :core:analytics module — declares @Inject constructor(private val interceptors: Set<Interceptor>) and receives all registered interceptors regardless of how many feature modules exist.

The interceptor chain is the textbook use case: each feature module contributes its OkHttp interceptor to the set, and the network module receives the complete set and registers all of them with the client. Adding a new feature's interceptor requires only a @Binds @IntoSet declaration in the feature module — the network module changes nothing. @IntoMap extends this to keyed architectures: @IntoMap @StringKey("profile") @Binds abstract fun bindProfileHandler(impl: ProfileDeepLinkHandler): DeepLinkHandler contributes to a Map<String, DeepLinkHandler>, enabling routing logic that looks up handlers by key without a giant when expression.

@JvmSuppressWildcards is a Kotlin-specific requirement. Kotlin compiles generic types with variance markers — Set<Interceptor> becomes Set<? extends Interceptor> in bytecode — and Dagger cannot match this against its binding. Adding @JvmSuppressWildcards to the injection site forces exact-type bytecode that Dagger can resolve: @Inject constructor(private val interceptors: Set<@JvmSuppressWildcards Interceptor>). Forgetting this annotation produces a confusing "no binding found" error. Multibindings implement the Open/Closed principle at the DI level: the system is open for extension by new modules and closed for modification — no existing code changes when new contributors are added.

💡 Interview Tip

Multibindings demonstrate the Open/Closed Principle with DI. "Adding a new OkHttp interceptor means adding @IntoSet in one new module. NetworkModule doesn't change. No other module knows about the new interceptor. This is exactly the plugin architecture that scales to large teams."

Mark it done or save it in the question bank · Back to top

Hard

15. Scenario: You have a circular dependency in your DI graph — A depends on B and B depends on A. How do you detect and fix it?

Circular dependencies are a build-time error in Hilt/Dagger — the graph literally can't be constructed. The fix is to break the cycle by introducing an abstraction, a lazy reference, or restructuring responsibilities.

// The circular dependency — Hilt/Dagger BUILD ERROR
class AuthRepository @Inject constructor(
    private val userRepo: UserRepository  // needs UserRepository
)
class UserRepository @Inject constructor(
    private val authRepo: AuthRepository  // needs AuthRepository → CYCLE!
)
// Build fails: "Found a dependency cycle"

// Fix 1: Extract shared dependency — break cycle with a third class
class TokenStorage @Inject constructor(...)   // shared, no deps on A or B

class AuthRepository @Inject constructor(private val storage: TokenStorage)
class UserRepository @Inject constructor(private val storage: TokenStorage)
// Both depend on TokenStorage — no cycle

// Fix 2: Lazy injection — defer resolution until first use
class AuthRepository @Inject constructor(
    private val userRepo: dagger.Lazy<UserRepository>  // Dagger.Lazy breaks cycle
) {
    fun doSomething() = userRepo.get().someMethod()   // resolved at call time
}

// Fix 3: Redesign — question whether the dependency is actually needed
// Often a circular dep signals an SRP violation
// "Why does UserRepository need AuthRepository?"
// "Why does AuthRepository need UserRepository?"
// Likely UserRepository should take a token directly, not AuthRepository
class UserRepository @Inject constructor(
    private val tokenProvider: TokenProvider  // interface, not AuthRepository
)

A circular dependency occurs when class A depends on class B and class B depends on class A — or through a longer chain. Dagger detects cycles at compile time and fails the build with "Found a dependency cycle," which is the right failure mode: a circular dependency is always a design problem. In a manual DI setup without compile-time validation, the same situation would cause a stack overflow at runtime or require lazy initialisation scattered throughout the code to avoid it.

There are three standard fixes, in order of preference. First, extract shared state: if A and B both depend on each other because they share some piece of state, extract that state into a class C. Both A and B depend on C; neither depends on the other. This is usually the right design anyway — the cycle revealed a missing abstraction. Second, use dagger.Lazy<T>: inject Lazy<ClassB> in A and call .get() only when needed. This defers B's instantiation past A's construction, breaking the cycle mechanically. Dagger accepts this because A's constructor no longer requires B to exist at construction time.

The third fix — and the most instructive — is redesign. A circular dependency almost always signals a Single Responsibility violation: one of the classes knows too much and has taken on responsibilities that belong elsewhere. A UserRepository that depends on an AuthManager that depends back on UserRepository suggests that authentication logic and user data access are incorrectly merged. Separating them — perhaps introducing a TokenStorage that AuthManager uses and UserRepository uses independently — resolves the cycle and produces a cleaner architecture. Treat Dagger's cycle error as a prompt to reconsider the design, not just to reach for Lazy.

💡 Interview Tip

A circular dependency is almost always a design smell, not just a DI problem. "If A needs B and B needs A, what does that tell us? One of them probably needs to be split — extract the shared concept into a third class that both depend on." Fix the design, not just the DI wiring.

Mark it done or save it in the question bank · Back to top

MediumMost asked

16. What is the service locator pattern? How does it differ from DI, and why is it considered an anti-pattern?

Service locator is a global registry where classes PULL their dependencies. DI PUSHES dependencies into classes. Service locator hides dependencies and makes testing harder — it's considered an anti-pattern.

// SERVICE LOCATOR — class pulls dependencies from a registry
object ServiceLocator {
    var userRepository: UserRepository = UserRepositoryImpl()
    var analytics: Analytics = FirebaseAnalytics()
}

class UserViewModel : ViewModel() {
    private val repo = ServiceLocator.userRepository  // PULLS from global
    // Problems:
    // 1. Hidden dependency — no way to know what ViewModel needs from signature
    // 2. Testing: must modify global ServiceLocator before each test
    // 3. Thread-safety: global mutable state
    // 4. Compile-time: wrong type → runtime crash, not build error
}

// DEPENDENCY INJECTION — dependencies pushed in
class UserViewModel @Inject constructor(
    private val repo: UserRepository   // PUSHED from outside
) : ViewModel() {
    // Explicit: anyone reading this knows it needs UserRepository
    // Testing: val vm = UserViewModel(FakeUserRepository())
    // Compile-time: Hilt catches missing bindings at build time
}

// Koin — technically a service locator (with DI-like DSL)
// get() pulls from Koin's registry at runtime
val vm: UserViewModel by viewModel()  // resolves from Koin registry

// Why service locator is called an anti-pattern:
// - Violates "tell, don't ask" principle
// - Hides coupling — class silently depends on global state
// - Testing requires global state mutation — fragile, order-dependent

The service locator pattern maintains a global registry of dependencies. Classes obtain what they need by calling into the registry: val repo = ServiceLocator.get(UserRepository::class). This is a PULL model — the class reaches out and takes what it needs. Dependency Injection inverts this: the caller provides what the class needs at construction. This is a PUSH model — the class declares its requirements in its constructor and receives them passively. The difference sounds subtle but has significant consequences for readability and testability.

The fundamental problem with service locator is hidden dependencies. Looking at a class's constructor tells you nothing about what it actually needs — the real dependencies are buried inside method bodies where they call the locator. You cannot tell from the outside what needs to be configured before using the class. Testing requires mutating global state: ServiceLocator.register(UserRepository::class, FakeUserRepository()) before each test and resetting after, which is fragile and non-parallel. With DI, a class's constructor is a complete, honest declaration of its dependencies. Tests construct instances directly with fakes — no global state, no setup/teardown, fully parallel.

Koin is technically a service locator — dependencies are resolved at runtime from a registry, not verified at compile time, and by inject() inside a class is a pull from the registry. However, Koin's structured DSL and module system provide significantly better organisation than ad-hoc service locators, and the practical trade-off is often acceptable. The key distinction to keep in mind when evaluating Koin is that its failure mode is a runtime crash rather than a build error, and its injection points are less visible than constructor parameters. These are real costs, not theoretical ones, that grow with team size and codebase complexity.

💡 Interview Tip

The key difference: "DI makes dependencies part of the class interface — you can see what it needs. Service locator hides dependencies inside the class body — you have to read the implementation to know what it depends on." Explicit over implicit is the principle.

Mark it done or save it in the question bank · Back to top

Hard

17. Scenario: You're migrating from manual DI (AppContainer) to Hilt in a large existing app. What's your strategy?

Migrating from manual DI to Hilt is an incremental process — never a big-bang rewrite. Convert the dependency graph leaf-first, validate as you go, and replace the AppContainer incrementally.

// Current state — manual AppContainer
class AppContainer(private val context: Context) {
    val okHttp by lazy { OkHttpClient()  }
    val retrofit by lazy { Retrofit.Builder().client(okHttp).build() }
    val userApi by lazy { retrofit.create(UserApi::class.java) }
    val userRepo by lazy { UserRepositoryImpl(userApi) }
}

// Migration steps:

// Step 1: Add Hilt dependencies + @HiltAndroidApp
@HiltAndroidApp class MyApp : Application() {
    val container by lazy { AppContainer(this) }  // keep during migration
}

// Step 2: Convert leaf dependencies first (no deps of their own)
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton fun provideOkHttp(): OkHttpClient = OkHttpClient()
    @Provides @Singleton fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder().client(client).build()
    @Provides @Singleton fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java)
}

// Step 3: Add @Inject constructor to UserRepositoryImpl
class UserRepositoryImpl @Inject constructor(private val api: UserApi) : UserRepository

// Step 4: Migrate ViewModels to @HiltViewModel one by one
// Step 5: Migrate Activities to @AndroidEntryPoint
// Step 6: Remove AppContainer once all deps are in Hilt

// Validate after each step: run all tests, check CI passes

Migrating from a manual AppContainer to Hilt works best incrementally, starting at the leaves of the dependency graph — classes that have no dependencies of their own. Add @Inject constructor to these first: @Inject constructor is valid Hilt annotation but also just a standard JSR-330 annotation that costs nothing if Hilt is not present yet. These classes can now be instantiated by Hilt when you are ready, without changing any of their callers immediately. Leaf-first migration means you are never in a state where Hilt tries to inject a class whose dependencies have not been migrated yet.

Keep the AppContainer running throughout the migration. Hilt and manual DI coexist perfectly — you can have some classes managed by Hilt and others still constructed manually in the container, with no conflicts. Remove each class from the container as its Hilt binding is validated. Migrate ViewModels one at a time by adding @HiltViewModel and @Inject constructor, running the relevant screen's tests, and confirming the ViewModel receives its dependencies before moving to the next. This approach makes each individual step small and verifiable.

Continuous validation is what makes incremental migration safe. Run your test suite after each migrated class — do not batch up 20 migrations and then test. Early detection of scope mismatches, missing modules, or accidentally broken bindings is far cheaper than debugging a tangled state where multiple changes interact. A useful heuristic: if a migration step requires touching more than two or three files, it is probably not incremental enough. The goal is a sequence of commits each of which compiles, passes tests, and moves one class from manual to Hilt management.

💡 Interview Tip

Leaf-first is the key strategy: start with classes that have no dependencies (OkHttpClient, Room builder) — they're easiest to wrap in @Provides. Then work up the dependency chain. Never try to migrate an Activity and its entire dep graph in one PR.

Mark it done or save it in the question bank · Back to top

MediumMost asked

18. What is lazy injection in Hilt/Dagger? How does dagger.Lazy<T> differ from Kotlin's lazy?

Dagger's Lazy<T> defers dependency creation until first call to get() — the dependency is still injected at construction, but the instance is created lazily. Different from Kotlin's lazy which controls property initialization.

// dagger.Lazy<T> — defer dependency CREATION until first use
class UserViewModel @Inject constructor(
    private val heavyService: dagger.Lazy<HeavyImageProcessingService>
) : ViewModel() {

    fun processImage(uri: Uri) {
        // HeavyImageProcessingService not created until here
        heavyService.get().process(uri)
    }
    // If processImage() never called → service never instantiated
}

// Kotlin lazy — defers property access, not DI injection
class UserViewModel @Inject constructor(
    private val repo: UserRepository   // injected at construction
) : ViewModel() {
    private val formattedDate by lazy {  // Kotlin lazy — property init
        SimpleDateFormat("dd/MM/yyyy").format(Date())
    }
}

// Key differences:
// dagger.Lazy: controls when the INJECTED OBJECT is created
// kotlin lazy:  controls when a PROPERTY VALUE is computed

// Use cases for dagger.Lazy:
// 1. Expensive objects only sometimes needed
// 2. Break circular dependencies (lazy breaks the cycle)
// 3. Optional features — inject but only create if feature flag enabled

// Provider<T> — related concept, creates a new instance on each get()
class SessionManager @Inject constructor(
    private val requestBuilder: Provider<AuthRequest>  // new instance each time
) {
    fun makeRequest() = requestBuilder.get()  // fresh AuthRequest every call
}

dagger.Lazy<T> and Provider<T> are Dagger's two mechanisms for deferred dependency resolution. With a plain injection — @Inject constructor(private val analytics: AnalyticsTracker) — Dagger creates the AnalyticsTracker when your class is constructed. With dagger.Lazy<AnalyticsTracker>, the tracker is not created until you call analytics.get() for the first time. Subsequent calls to .get() return the same cached instance. This is useful when the dependency is expensive to create and is only needed on certain code paths — no need to pay the creation cost at every injection point's construction time.

Provider<T> differs in one important way: every call to .get() creates a new instance (unless the type is scoped — a scoped type returns the scoped instance each time). This makes Provider<T> appropriate for prototype-scoped objects that represent units of work: each background task gets its own fresh instance of a JobExecutor, for example. Kotlin's by lazy is a completely separate mechanism — it defers property initialisation in Kotlin and has no connection to Dagger or Hilt. Do not confuse them: by lazy is for Kotlin property delegation, dagger.Lazy is for deferred DI resolution.

dagger.Lazy is thread-safe by specification: the first call to .get() may be called from multiple threads simultaneously and only one instance is created, consistent with the double-checked locking guarantee. This makes it appropriate for use in background threads without additional synchronisation. Its two main use cases are: expensive, rarely-needed services where you do not want to pay creation cost at startup, and breaking circular dependencies where one side of the cycle can tolerate deferred access. In both cases, inject Lazy<T> rather than T in the constructor signature — Hilt handles the rest.

💡 Interview Tip

dagger.Lazy is the answer to "how do you break a circular dependency in Dagger?" Wrap one side in Lazy<T> — Dagger defers the instantiation, allowing the graph to be constructed. The circular reference is resolved at runtime on first .get() call.

Mark it done or save it in the question bank · Back to top

Hard

19. Scenario: You need to inject a dependency into a WorkManager Worker. How does Hilt support this?

WorkManager Workers are created by the system, not by your code — you need a special Hilt worker factory to inject dependencies. This is one of Hilt's built-in integrations.

// Dependencies
// implementation("androidx.hilt:hilt-work:1.2.0")
// ksp("androidx.hilt:hilt-compiler:1.2.0")

// Step 1: Use @HiltWorker and @AssistedInject on Worker
@HiltWorker
class SyncWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted workerParams: WorkerParameters,
    private val syncRepository: SyncRepository,   // ✅ injected!
    private val userRepo: UserRepository           // ✅ injected!
) : CoroutineWorker(appContext, workerParams) {

    override suspend fun doWork(): Result {
        return try {
            val users = userRepo.getAll()
            syncRepository.sync(users)
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
}

// Step 2: Register HiltWorkerFactory in Application
@HiltAndroidApp
class MyApp : Application(), Configuration.Provider {
    @Inject lateinit var workerFactory: HiltWorkerFactory

    override val workManagerConfiguration: Configuration
        get() = Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
}

// Step 3: Remove WorkManager default initializer from Manifest
// AndroidManifest.xml
<provider android:name="androidx.startup.InitializationProvider">
  <meta-data android:name="androidx.work.WorkManagerInitializer"
             tools:node="remove" />  <!-- remove default init -->
</provider>

// Enqueue as normal
WorkManager.getInstance(context)
    .enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build())

WorkManager creates Workers via reflection using a no-argument constructor, which conflicts with Hilt's constructor injection. The solution is the @HiltWorker annotation combined with @AssistedInject. Mark the Worker class with @HiltWorker and its constructor with @AssistedInject. The two parameters WorkManager always provides — context: Context and workerParams: WorkerParameters — are marked with @Assisted. All other constructor parameters are injected normally by Hilt. The result is a Worker that receives its business-logic dependencies from Hilt and its system parameters from WorkManager, with the wiring handled transparently.

The bridge between Hilt and WorkManager is HiltWorkerFactory. WorkManager has a configuration API that accepts a custom WorkerFactory. HiltWorkerFactory is Hilt's implementation of this factory — it intercepts Worker creation requests and uses the Hilt component to resolve injected parameters before calling the @AssistedInject constructor. You set it by implementing Configuration.Provider on your Application class and returning a Configuration built with HiltWorkerFactory injected into the Application via @Inject.

There is a critical setup step that is easy to miss: you must disable WorkManager's default initialiser in AndroidManifest.xml. WorkManager ships with a WorkManagerInitializer ContentProvider that auto-initialises it on app startup before your Application code runs. If this fires before Hilt initialises, HiltWorkerFactory is not yet available and Worker creation fails at runtime. Disable it by adding <provider android:name="androidx.work.impl.WorkManagerInitializer" tools:node="remove" /> to your manifest. Without this step, the Hilt WorkManager integration will fail intermittently depending on initialisation order.

💡 Interview Tip

Forgetting to remove the default WorkManager initializer from the Manifest is the most common mistake. WorkManager initializes itself via Jetpack Startup before your Application.onCreate() runs — so HiltWorkerFactory isn't available yet and injection fails at runtime.

Mark it done or save it in the question bank · Back to top

Hard

20. What is Assisted Injection in Hilt? When do you need it and how does it work?

Assisted injection mixes Hilt-provided dependencies with runtime parameters — for objects that need both static DI-managed deps AND dynamic values known only at creation time.

// Problem: ProductDetailViewModel needs both repo (DI) AND productId (runtime)
// Can't inject productId via Hilt — it's known only when user taps a product

// Solution: @AssistedInject + @AssistedFactory
class ProductDetailViewModel @AssistedInject constructor(
    @Assisted val productId: String,           // runtime — not from DI
    private val productRepo: ProductRepository,  // from DI
    private val cartRepo: CartRepository          // from DI
) : ViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(productId: String): ProductDetailViewModel
    }
}

// In the Composable — use the factory
@Composable
fun ProductDetailScreen(
    productId: String,
    viewModel: ProductDetailViewModel = hiltViewModel(
        creationCallback = { factory: ProductDetailViewModel.Factory ->
            factory.create(productId)
        }
    )
) {
    // viewModel.productId == productId
}

// Pre-Hilt 2.49 — manual factory in Fragment
@AndroidEntryPoint
class ProductDetailFragment : Fragment() {
    @Inject lateinit var factory: ProductDetailViewModel.Factory

    private val productId by navArgs<ProductDetailArgs>()
    private val viewModel by viewModels {
        viewModelFactory { initializer { factory.create(productId.id) } }
    }
}

Assisted injection handles the case where a class needs both dependencies from Hilt and parameters only known at runtime. A ViewModel that needs a productId from navigation arguments is the canonical example: Hilt can provide the repository and dispatcher, but the specific product ID is not known at DI graph construction time. Without assisted injection you would pass the ID through a SavedStateHandle (which works but is indirect) or build a manual factory (which is boilerplate). With assisted injection, the constructor declares both: @AssistedInject constructor(@Assisted val productId: String, private val repo: ProductRepo).

Hilt generates the factory based on an interface you define: @AssistedFactory interface Factory { fun create(productId: String): ProductDetailViewModel }. The interface's create method takes the runtime parameters and returns the fully-constructed object with all Hilt dependencies resolved. You inject the Factory interface — not the ViewModel directly — and call factory.create(productId) when you have the runtime value. Hilt validates the assisted parameter types match between the @Assisted constructor annotations and the factory method at compile time.

In Compose with Hilt 2.49+, hiltViewModel(creationCallback = { factory: ProductDetailViewModel.Factory -> factory.create(productId) }) integrates assisted injection directly into the Compose ViewModel retrieval API. This is cleaner than manually obtaining the factory and calling create. Use cases beyond nav-arg ViewModels include: Workers that need job-specific parameters beyond what WorkerParameters provides, scoped caches keyed to a specific entity, and any service that needs both injected infrastructure and caller-provided configuration. Assisted injection is Hilt's answer to the "partially-configurable at runtime" dependency problem.

💡 Interview Tip

Assisted injection solves the "ViewModel needs productId" problem. Before it existed, you'd pass productId via SavedStateHandle (navigation args). Both work — but @AssistedInject makes the dependency explicit in the constructor, which is cleaner and more testable.

Mark it done or save it in the question bank · Back to top

Hard

21. Scenario: Your team decides to adopt Hilt but the senior dev says "We already have clean interfaces — DI without a framework is enough." How do you respond?

The objection to Hilt is usually about build time overhead from annotation processing. The answer: migrate annotation processors from KAPT to KSP (2x faster), use Hilt's incremental processing, and benchmark before vs after. Hilt's compile-time safety -- catching missing bindings before the app runs -- is worth the setup cost.

// KAPT (slow) → KSP (fast) migration for Hilt
// build.gradle.kts: replace kapt with ksp
plugins { alias(libs.plugins.ksp) }  // add KSP plugin
dependencies {
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)          // was: kapt(libs.hilt.compiler)
    ksp(libs.hilt.compiler.testing)
}

// Hilt incremental processing -- gradle.properties
ksp.incremental=true
ksp.incremental.apt=true

// Component hierarchy -- compile-time validated
@HiltAndroidApp class MyApp : Application()  // generates SingletonComponent
@AndroidEntryPoint class MainActivity : ComponentActivity()  // ActivityComponent

The most common legitimate criticism of Hilt is build time: Dagger's annotation processing generates significant amounts of code, and in a large project this can add tens of seconds to clean builds. The correct response to this concern is the KAPT-to-KSP migration, not abandoning Hilt. KSP processes annotations as part of the Kotlin compilation pass rather than as a blocking pre-compilation step, and the gains are substantial — typically 30–60 seconds on large clean builds. Combined with ksp.incremental=true in gradle.properties, which limits reprocessing to changed files on incremental builds, the build-time argument against Hilt largely disappears.

The compile-time safety argument is Hilt's strongest selling point and should be understood concretely. When you forget to provide a binding, Hilt fails the build with a clear error message pointing to the missing dependency. Koin surfaces the same problem as a NoBeanDefFoundException crash when that code path is first exercised at runtime — which might be in production, by a user, weeks after the module was written. Manual DI fails with a NullPointerException or a runtime exception when the missing construction is first called. Build-time failures are caught by CI on every commit; runtime failures require test coverage that may not exist for every code path.

Manual DI is often presented as the simpler alternative, but its simplicity is superficial. For graphs of five or ten classes it is genuinely simpler. As the graph grows to fifty or a hundred classes, manual DI becomes hundreds of lines of factory boilerplate — constructors threaded through intermediate classes that do not use them, scope management that must be manually replicated everywhere, and zero framework help when a dependency is missing. Hilt generates all of that boilerplate automatically and validates it at compile time. The testing story is similar: @UninstallModules and @BindValue make dependency swapping declarative; manual DI requires either constructor threading or exposing internal setters for test purposes.

💡 Interview Tip

The right answer acknowledges the senior dev is correct: "The principles are sound — Hilt adds automated enforcement and ecosystem integration on top. The question isn't DI vs no-DI, it's manual-DI vs automated-DI. At 50+ classes with a team, the compile-time safety and scope management automation justify Hilt."

Mark it done or save it in the question bank · Back to top

MediumMost asked

22. What is optional injection in Hilt? How do you handle nullable or optional dependencies?

Hilt doesn't natively support nullable injection — if a binding is missing, it's a build error. Optional dependencies are handled by providing a null or default implementation, or using Kotlin optional types with special handling.

// Hilt/Dagger: @Nullable injection with Optional<T>
@Module @InstallIn(SingletonComponent::class)
object OptionalModule {
    // Provide null for optional feature
    @Provides
    fun provideCrashlytics(): Optional<FirebaseCrashlytics> =
        if (BuildConfig.ENABLE_CRASHLYTICS)
            Optional.of(FirebaseCrashlytics.getInstance())
        else
            Optional.empty()
}

class CrashReporter @Inject constructor(
    private val crashlytics: Optional<FirebaseCrashlytics>
) {
    fun report(e: Throwable) {
        crashlytics.ifPresent { it.recordException(e) }
    }
}

// Cleaner Kotlin approach: provide a no-op implementation
interface CrashTracker { fun record(e: Throwable) }

class NoOpCrashTracker @Inject constructor() : CrashTracker {
    override fun record(e: Throwable) { }  // does nothing
}
class FirebaseCrashTracker @Inject constructor() : CrashTracker {
    override fun record(e: Throwable) { FirebaseCrashlytics.getInstance().recordException(e) }
}

// Bind NoOp in debug, Firebase in release — via source set modules
// src/debug/java/CrashModule.kt → @Binds NoOpCrashTracker
// src/release/java/CrashModule.kt → @Binds FirebaseCrashTracker

// Consumers inject CrashTracker — never null, always has an impl
class UserViewModel @Inject constructor(
    private val crash: CrashTracker   // always safe to call
)

Hilt has no built-in concept of optional bindings — if a binding is declared, it must be provided, or the build fails. This is a feature, not a limitation: nullable injection @Inject val tracker: CrashTracker? is not supported, and if it were, it would force null-checks throughout the codebase and create a class of bugs where a tracker is unexpectedly absent at runtime. The Hilt philosophy is that the dependency graph is either complete or it does not compile.

The canonical solution for "optionality" in Hilt is the No-op pattern. Instead of providing a nullable binding, always provide a real binding — in cases where the service should do nothing, provide a no-op implementation: class NoOpCrashTracker : CrashTracker { override fun report(e: Throwable) = Unit }. Every injection site receives a valid, non-null object. Callers never check for null. The "optionality" is expressed at the module level — the production module binds FirebaseCrashTracker, the debug module binds LoggingCrashTracker, the test module binds NoOpCrashTracker. This is cleaner than any nullable-injection mechanism.

Source-set modules are the structured version of this approach. If a feature should genuinely not exist in certain build variants — perhaps a premium analytics tracker that only exists in the pro flavor — the pro source set provides the real module and the free source set provides the no-op module. The injection site is identical in both variants; the build system selects the correct implementation. This is type-safe, produces no null checks, and is enforced at compile time. Java's Optional<T> wrapped in a @Provides method is a secondary option for cases where the presence or absence is genuinely data rather than a build-time decision, but the no-op pattern is almost always the better design.

💡 Interview Tip

The no-op pattern is cleaner than Optional: "Instead of Optional<CrashTracker> with null checks everywhere, provide NoOpCrashTracker in debug and FirebaseCrashTracker in release. Callers always call crash.record(e) — no null checks, no Optional.ifPresent — just works."

Mark it done or save it in the question bank · Back to top

Hard

23. Scenario: Review this Hilt code — find all the problems and fix them.

DI code review questions test whether you can spot scope mistakes, context leaks, incorrect annotation usage, and structural issues simultaneously.

// ❌ BUGGY CODE — find all 5 issues
@HiltAndroidApp
class MyApp : Application()

@Module @InstallIn(SingletonComponent::class)
object AppModule {
    @Provides                              // Bug 1: missing @Singleton!
    fun provideDatabase(context: Context): AppDatabase =   // Bug 2: unqualified Context!
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()

    @Binds                                 // Bug 3: @Binds in object (non-abstract)!
    fun bindRepo(impl: UserRepositoryImpl): UserRepository = impl
}

@HiltViewModel
class UserViewModel(private val repo: UserRepository) : ViewModel()  // Bug 4: missing @Inject!

class UserActivity : AppCompatActivity() {           // Bug 5: missing @AndroidEntryPoint!
    @Inject lateinit var analytics: Analytics
}

// ✅ FIXED VERSION
@Module @InstallIn(SingletonComponent::class)
abstract class AppModule {                             // Fix 3: abstract class for @Binds
    @Binds @Singleton
    abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository

    companion object {
        @Provides @Singleton                           // Fix 1: @Singleton
        fun provideDatabase(
            @ApplicationContext ctx: Context          // Fix 2: @ApplicationContext
        ): AppDatabase = Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()
    }
}

@HiltViewModel
class UserViewModel @Inject constructor(             // Fix 4: @Inject
    private val repo: UserRepository
) : ViewModel()

@AndroidEntryPoint                                     // Fix 5
class UserActivity : AppCompatActivity()

Five common Hilt bugs worth knowing for code review. Bug one: @Provides fun provideDatabase(context: Context): AppDatabase without @Singleton. Room databases are expensive to construct and designed to be shared. Without the scope annotation, Hilt creates a new database instance every time something injects AppDatabase — potentially dozens of instances competing for the same SQLite file, with separate connection pools and caches. Adding @Singleton to the @Provides function fixes it. Bug two: bare Context as a parameter in a Singleton-scoped @Provides method. Hilt requires a qualifier: @ApplicationContext Context or @ActivityContext Context. Injecting Activity context into a Singleton causes a memory leak — the Activity is held alive by the Singleton long after the user navigates away.

Bug three: @Binds in a Kotlin object (companion or top-level). @Binds requires an abstract function, and functions in a Kotlin object are concrete (they have implementations — they are static dispatches). The fix is to change the module to an abstract class with an abstract fun. If you also need @Provides functions in the same module, put them in a companion object inside the abstract class. Bug four: @HiltViewModel class MyViewModel : ViewModel() without @Inject constructor(). Hilt discovers the ViewModel's dependencies by inspecting its constructor for the @Inject annotation. Without it, Hilt cannot generate the factory and the build fails with a confusing missing-binding error rather than a clear "missing @Inject" message.

Bug five: a class with @Inject lateinit var service: SomeService inside a Fragment or Activity that is not annotated with @AndroidEntryPoint. The @Inject annotation on a field is a request for injection, but without @AndroidEntryPoint, Hilt never calls the injection method. The field is never populated. When the field is first accessed, Kotlin throws UninitializedPropertyAccessException. The fix is adding @AndroidEntryPoint to the class. This is particularly insidious in Fragments that inherit from a base Fragment that already has @AndroidEntryPoint — the annotation is not inherited; every leaf class that needs injection must be annotated.

💡 Interview Tip

Spotting all 5: "I see missing @Singleton on database, unqualified Context in Singleton (memory leak risk), @Binds in object instead of abstract class, @HiltViewModel without @Inject constructor, and missing @AndroidEntryPoint for field injection." Systematic enumeration shows real-world Hilt experience.

Mark it done or save it in the question bank · Back to top

MediumMost asked

24. What is the purpose of @ApplicationContext and @ActivityContext in Hilt?

Hilt provides two qualifier annotations to inject Android Context correctly — preventing the most common memory leak of using Activity context where Application context is appropriate.

// Context types in Android:
// Application Context — lives as long as the app, no UI
// Activity Context   — tied to Activity lifecycle, has UI theming

// @ApplicationContext — safe for @Singleton dependencies
@Module @InstallIn(SingletonComponent::class)
object AppModule {
    @Provides @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context  // ✅ app-scoped, no leak
    ): AppDatabase = Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()

    @Provides @Singleton
    fun provideNotificationManager(
        @ApplicationContext context: Context
    ): NotificationManager = context.getSystemService(NotificationManager::class.java)!!
}

// @ActivityContext — only valid in ActivityComponent or narrower
@Module @InstallIn(ActivityComponent::class)
object ActivityModule {
    @Provides @ActivityScoped
    fun provideLayoutInflater(
        @ActivityContext context: Context   // ✅ Activity context — for UI inflation
    ): LayoutInflater = LayoutInflater.from(context)
}

// ❌ LEAK: Using Activity context in @Singleton
@Provides @Singleton
fun provideThemeHelper(context: Context): ThemeHelper =   // Which Context? Hilt error!
    ThemeHelper(context)                                   // If Activity: leak!

// Without qualifier: Hilt build error — ambiguous Context
// With @ApplicationContext: safe
// With @ActivityContext in SingletonComponent: build error — scope mismatch

@ApplicationContext and @ActivityContext are qualifiers that tell Hilt which Context instance to provide. @ApplicationContext provides the Application object, which lives for the entire process lifetime. It is safe to inject into any scope, including @Singleton. @ActivityContext provides the current Activity instance, which lives only as long as that Activity. It is only valid in ActivityComponent scope or narrower. Hilt enforces these lifetime rules: using @ActivityContext in a @Singleton-scoped binding is a build error — Hilt detects that an Activity-scoped dependency cannot be safely held by a singleton.

Unqualified Context injection is a build error in Hilt — unlike Dagger 2 where you might get away with it if you set up the component correctly, Hilt requires you to be explicit. This is intentional: the most common memory leak in Android is holding an Activity context in a long-lived object (a singleton, a static field, a background thread's closure). By forcing the qualifier, Hilt makes this mistake explicit and visible in every binding declaration rather than hidden inside a constructor call.

Activity context is genuinely needed in specific situations: layout inflation that respects the Activity's theme, creating themed dialogs, accessing resources that are Activity-window-aware, or using APIs that require an Activity rather than a plain Context. In these cases, the class that needs Activity context should itself be scoped to ActivityComponent or narrower. If a class scoped to @Singleton seems to need Activity context, it is almost always a design error — either the singleton should not exist, or the Activity-aware work should be delegated to a narrower-scoped collaborator that is passed in at call time rather than injected.

💡 Interview Tip

"When in doubt, use @ApplicationContext. You ONLY need @ActivityContext when you specifically need Activity-scoped features: themed resources, layout inflation with activity theme, or Activity-specific system services. Room, Retrofit, SharedPreferences — all take Application context."

Mark it done or save it in the question bank · Back to top

Hard

25. Scenario: You're leading a team and a junior asks "when should I NOT use DI?" — how do you answer?

DI is a pattern with real costs — annotation processing overhead, boilerplate, and learning curve. Knowing when NOT to use it shows architectural maturity over dogmatic application.

// Cases where DI adds more complexity than value:

// 1. Utility classes / pure functions — no state, no dependencies
object DateFormatter {
    fun format(ts: Long): String = SimpleDateFormat("dd/MM").format(Date(ts))
}
// No DI needed — just call DateFormatter.format(ts). It's a function.

// 2. Data classes / value objects
data class Money(val amount: Double, val currency: String) {
    operator fun plus(other: Money) = Money(amount + other.amount, currency)
}
// Data has no external dependencies — DI would be wrong here

// 3. Simple scripts / one-off tools
// A Gradle task, a migration script, a CLI tool
// DI setup overhead exceeds the benefit for 50-line programs

// 4. Sealed class hierarchies / algebraic types
sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
}
// These are type definitions, not services — DI doesn't apply

// 5. Inlined/inline functions — compile away entirely
inline fun <reified T> fromJson(json: String): T = Gson().fromJson(json, T::class.java)

// The DI rule: use DI when:
// ✅ Object has state that needs to be shared
// ✅ Object needs to be swapped for a fake in tests
// ✅ Object has expensive construction (DB, network)
// ✅ Object has a lifecycle (scope, creation, destruction)
// ❌ Skip DI: stateless utilities, data classes, type definitions

Dependency injection solves specific problems: sharing instances, managing lifecycles, and enabling test substitution. For code that has none of these requirements, DI adds friction without benefit. Stateless utility functions — a date formatter, a string sanitiser, a math helper — are pure functions that take inputs and return outputs with no external dependencies. Wrapping them in an injected class just to be "architecturally consistent" forces callers to declare a dependency on something trivial and makes injection graphs larger and harder to read. Call them directly.

Data classes and value objects represent data, not services. A User(id: String, name: String, email: String) has no dependencies because it is not responsible for any behaviour that requires collaborators — it is a record. Sealed classes defining states or events, enums defining options, and simple type aliases are similarly DI-inappropriate. They are types, not services. Attempting to inject them through Hilt or any DI framework would require bizarre factory bindings for objects that are typically constructed with literal values at the call site.

The diagnostic question for whether something should be injected is: does this class have state that needs to be shared, a lifecycle that needs to be managed, or a concrete implementation that needs to be swapped in tests? If all three answers are no, DI is probably the wrong tool. A class that is always stateless, always has exactly one correct implementation, and is never tested in isolation is better served by a static function or a simple object. The value of DI is proportional to the complexity of the object graph and the need for flexibility — applying it uniformly to every class in a codebase is a form of over-engineering that makes the system harder, not easier, to understand.

💡 Interview Tip

Knowing when NOT to use a pattern demonstrates mastery. "DI is for services — objects with state, lifecycle, and external dependencies. DateFormatter with a static format() method? Just call it. UserRepository with network and database deps that need mocking? DI it. The question is always: do I need to control this object's creation and lifetime?"

Mark it done or save it in the question bank · Back to top

Hard

26. How does Hilt handle ViewModel injection under the hood? What does @HiltViewModel actually generate?

@HiltViewModel triggers compile-time code generation — Hilt creates a ViewModelFactory that Dagger wires automatically. Understanding what's generated explains why certain patterns work and others don't.

// What you write:
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repo: UserRepository,
    private val saved: SavedStateHandle
) : ViewModel()

// What Hilt generates (conceptually):
// 1. UserViewModel_HiltModules — @Module that binds the VM
// 2. UserViewModel_Factory — implements ViewModelProvider.Factory
class UserViewModel_Factory(
    private val repoProvider: Provider<UserRepository>,
    private val savedProvider: Provider<SavedStateHandle>
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return UserViewModel(repoProvider.get(), savedProvider.get()) as T
    }
}

// 3. Hilt injects this factory into @AndroidEntryPoint components
// 4. hiltViewModel() or by viewModels() picks it up automatically

// Why SavedStateHandle is auto-injected:
// Hilt's ViewModelComponent provides SavedStateHandle from ViewModelStoreOwner
// This is why you DON'T need @Provides for SavedStateHandle
@HiltViewModel
class SearchViewModel @Inject constructor(
    private val saved: SavedStateHandle   // just declare it — Hilt provides it
) : ViewModel() {
    val query = saved.getStateFlow("q", "")
}

// Common mistake: @HiltViewModel without @Inject constructor
@HiltViewModel
class BrokenViewModel(private val repo: UserRepository) : ViewModel()
// Build error: @HiltViewModel must have @Inject constructor
// Hilt can't generate a factory without knowing how to create it

When you annotate a ViewModel with @HiltViewModel, Hilt's annotation processor generates a ViewModelProvider.Factory at compile time. This factory knows exactly how to construct the ViewModel — it uses a Provider<T> for each constructor parameter so the dependencies are resolved lazily when the ViewModel is first requested. There is no reflection involved in the construction path; the generated code calls constructors directly, making it as fast as hand-written factory code.

SavedStateHandle is special-cased in Hilt's ViewModelComponent: it is automatically provided without any module declaration. When a @HiltViewModel class declares @Inject constructor(private val handle: SavedStateHandle), Hilt's generated factory passes the correct SavedStateHandle from the AbstractSavedStateViewModelFactory lifecycle. You never need to register it or write a @Provides method — Hilt handles it automatically because it understands the Android ViewModel lifecycle at the framework level.

The @Inject constructor is non-negotiable for @HiltViewModel classes. Hilt reads the constructor signature to determine what bindings to wire into the generated factory. Without @Inject, the processor cannot identify the constructor to use and generates a build error. In Compose, hiltViewModel() is syntactic sugar: it looks up the Hilt-generated factory from the nearest HiltViewModelComponent in the composition hierarchy and passes it to the standard viewModel(factory) API. The result is a fully dependency-injected ViewModel scoped correctly to the composable's lifecycle.

💡 Interview Tip

"@HiltViewModel generates a ViewModelProvider.Factory at compile time using Provider<T> for each dependency. This is why it's type-safe and fast — no reflection, no runtime graph traversal. The factory is wired into the Activity/Fragment's ViewModelStore automatically by @AndroidEntryPoint."

Mark it done or save it in the question bank · Back to top

Hard

27. Scenario: Your app has a complex DI graph with 50+ modules. Hilt's build time is slow. How do you diagnose and optimise it?

Large Hilt graphs slow down because KSP/Dagger must process and validate the entire component tree. Strategic module restructuring and build configuration tuning are the main levers.

// Step 1: Measure annotation processing time
// ./gradlew :app:kspDebugKotlin --profile
// Check build/reports/profile — how long does KSP take?

// Step 2: KAPT → KSP (if not done yet)
// kapt("com.google.dagger:hilt-compiler:2.51") → slow
// ksp("com.google.dagger:hilt-compiler:2.51")  → 30-50% faster

// Step 3: Reduce @Singleton overuse
// Every @Singleton binding is part of the app-level component
// Dagger validates the ENTIRE Singleton graph on every build
// Move to narrower scopes where possible:
@ViewModelScoped   // only in ViewModelComponent
@ActivityScoped    // only in ActivityComponent
// Fewer @Singleton bindings = smaller graph to validate

// Step 4: Split large @InstallIn(SingletonComponent) modules
// Instead of one giant AppModule with 30 @Provides:
@Module @InstallIn(SingletonComponent::class) object NetworkModule  { /* ... */ }
@Module @InstallIn(SingletonComponent::class) object DatabaseModule { /* ... */ }
@Module @InstallIn(SingletonComponent::class) abstract class RepoModule  { /* ... */ }
// Dagger processes modules in parallel — split enables more parallelism

// Step 5: Gradle optimisations
// gradle.properties
ksp.incremental=true      // KSP incremental processing
ksp.incremental.apt=true  // incremental for mixed KAPT/KSP
org.gradle.caching=true   // cache KSP outputs

// Step 6: Avoid @InstallIn on test modules in production source
// Test modules compiled into production = larger validation graph

The single highest-leverage Hilt build performance improvement is migrating from KAPT to KSP. KAPT runs as a blocking pre-compilation phase: it generates Java stubs from Kotlin, processes annotations on those stubs, generates source files, and only then starts compilation. KSP is integrated into the Kotlin compiler plugin API and processes annotations during compilation, eliminating the stub generation step. Clean build time reductions of 30–50% are typical, and the improvement compounds across every module in a multi-module project that processes Hilt annotations.

KSP incremental processing is the other major win. With ksp.incremental=true in gradle.properties, KSP tracks which files contributed to each output and only reprocesses files that changed. In a codebase where you typically edit a handful of files, this means most modules' annotation processing is completely skipped on incremental builds. Gradle's build cache further multiplies this: KSP outputs are cacheable artifacts. If another developer on your team built the same module at the same commit, Gradle retrieves the cached output rather than reprocessing — this is especially powerful on CI.

Beyond toolchain changes, the DI graph itself can be optimised for build performance. Fewer @Singleton scopes mean a smaller graph for Dagger to validate — every scoped binding adds to the validation surface. Splitting monolithic @Module classes into smaller, focused modules allows Dagger's processor to handle them in parallel. The most actionable audit is checking whether @Singleton is being applied as a default habit rather than because sharing is genuinely needed. Removing unnecessary scopes simplifies the graph, speeds the build, and is often the right design anyway.

💡 Interview Tip

The biggest win after KSP migration is incremental KSP: ksp.incremental=true means only changed files trigger reprocessing. Without it, any module change forces full KSP re-run on that module. Combined with Gradle caching, unchanged modules are never touched.

Mark it done or save it in the question bank · Back to top

Hard

28. What is Hilt's component hierarchy? How do child components inherit from parent components?

Hilt's components form a strict hierarchy — child components can access all bindings from parent components, but parents can't access child bindings. This enforces proper scoping and lifetime management.

// Hilt Component Hierarchy:
// SingletonComponent (Application)
//   └── ActivityRetainedComponent
//         └── ViewModelComponent
//         └── ActivityComponent
//               └── FragmentComponent
//               └── ViewWithFragmentComponent
//         └── ServiceComponent

// Inheritance: child can use parent's bindings
// OkHttpClient is @Singleton → accessible in ViewModelComponent
@HiltViewModel
class UserViewModel @Inject constructor(
    private val client: OkHttpClient   // @Singleton — accessible from child
) : ViewModel()

// Parent CAN'T use child's bindings
// @ActivityScoped in a @Singleton-installed module → BUILD ERROR
@Module @InstallIn(SingletonComponent::class)  // ❌
object WrongModule {
    @Provides @ActivityScoped   // SCOPE MISMATCH — Activity dies before Singleton
    fun provideHelper(): ActivityHelper = ActivityHelper()
}

// ViewModelComponent — between ActivityRetained and Activity
// @ViewModelScoped deps are NOT shared between ViewModels
@Module @InstallIn(ViewModelComponent::class)
abstract class ViewModelModule {
    @Binds @ViewModelScoped
    abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository
}
// UserViewModel gets its OWN UserRepositoryImpl
// ProductViewModel gets its OWN UserRepositoryImpl
// They are NOT shared — correct for stateful repos

// ActivityRetainedComponent — survives rotation
// Parent of both ViewModelComponent AND ActivityComponent
// @ActivityRetainedScoped = shared across ViewModel + Activity of same instance

Hilt's component hierarchy is a tree of nested scopes: SingletonComponent is the root, ActivityRetainedComponent is its child, and from there the tree branches into ViewModelComponent and ActivityComponent, with FragmentComponent and ViewComponent as further leaves. The hierarchy defines two rules: a child component can access all bindings from its parent components, and a parent component cannot access any binding from its children. A ViewModel can inject a @Singleton-scoped OkHttpClient because ViewModelComponent is a child of SingletonComponent. The reverse is impossible by design.

The reason parent-to-child access is forbidden is lifetime: a @Singleton dependency lives for the entire app. An @ActivityScoped dependency lives only as long as one Activity instance. If a Singleton held a reference to an Activity-scoped dependency, the Activity could be destroyed while the Singleton — and its reference — lived on. This is the definition of an Activity memory leak. Hilt enforces this at compile time: trying to inject an @ActivityScoped binding into a SingletonComponent module produces a build error about incompatible scopes.

@ViewModelScoped has a subtlety worth understanding: it is scoped to a single ViewModel instance, not to the ViewModelComponent in general. If two different ViewModels both inject a @ViewModelScoped class, each gets its own instance — they are not shared. This is distinct from @ActivityRetainedScoped, which is shared across all ViewModels within the same Activity's retained scope. Choosing the wrong scope here causes either unexpected data sharing between screens (@ActivityRetainedScoped when per-ViewModel isolation was intended) or unnecessary duplication (unscoped when sharing was intended).

💡 Interview Tip

The interview test: "Can a @Singleton dependency inject an @ActivityScoped dependency?" No — Singleton lives longer than Activity, so this would be a scope mismatch. Hilt catches it at build time. The rule: a dependency can only be injected into components of equal or narrower lifetime.

Mark it done or save it in the question bank · Back to top

Hard

29. Scenario: You need to inject a dependency that's constructed from a third-party SDK that initialises asynchronously. How do you handle async initialisation in Hilt?

Hilt's DI graph is synchronous — all @Provides methods run on the main thread at injection time. Async-init SDKs need a wrapper pattern that bridges async init with synchronous DI.

// Problem: SDK initialises asynchronously
class SomeSDK {
    companion object {
        fun initialise(ctx: Context, callback: (SomeSDK) -> Unit) { /* async */ }
    }
}

// Solution 1: Suspend initialisation in App.onCreate with coroutines
@HiltAndroidApp
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        // Don't block onCreate — initialise lazily
    }
}

// Solution 2: Deferred / lazy holder pattern
class SdkHolder @Inject constructor(
    @ApplicationContext private val ctx: Context
) {
    private val _sdk = CompletableDeferred<SomeSDK>()
    val sdk: Deferred<SomeSDK> = _sdk

    fun initialise() {
        SomeSDK.initialise(ctx) { sdk ->
            _sdk.complete(sdk)
        }
    }
}

// Inject SdkHolder — it's available immediately (it's a wrapper)
@Singleton
class AnalyticsRepository @Inject constructor(
    private val sdkHolder: SdkHolder
) {
    suspend fun track(event: String) {
        sdkHolder.sdk.await().track(event)  // suspends until SDK ready
    }
}

// Kick off init in Application.onCreate:
@HiltAndroidApp
class MyApp : Application() {
    @Inject lateinit var sdkHolder: SdkHolder
    override fun onCreate() {
        super.onCreate()
        sdkHolder.initialise()   // starts async init
    }
}

@Provides functions are synchronous and run on the main thread during component initialisation. Blocking a @Provides function with an async operation — calling runBlocking, waiting on a callback, or sleeping — will freeze the main thread and trigger an ANR. Any SDK that requires async initialisation cannot be directly returned from a @Provides function. The solution is to separate construction from readiness: provide a holder or wrapper that is constructed synchronously and handles the async initialisation internally.

The CompletableDeferred<T> pattern bridges async SDK callbacks into the coroutine world cleanly. Create a wrapper class whose constructor is injectable synchronously, but whose property that exposes the SDK is a suspend fun awaitSdk(): T that await()s on an internal CompletableDeferred. The constructor kicks off SDK initialisation as a side effect, and the callback completes the deferred when the SDK is ready. Callers inject the wrapper and await in a coroutine — the SDK is ready before any business logic runs, and the @Provides function returns instantly.

Starting async initialisation early is the key to ensuring the SDK is ready before first use. Launch the initialisation in Application.onCreate() — a GlobalScope.launch is one of the few places where GlobalScope is appropriate, since the initialisation is genuinely application-scoped and must outlive any individual screen. The App Startup library offers a structured alternative: declare an Initializer in the manifest, and the library handles scheduling the initialisation in a background thread before the app's first Activity launches. Both approaches ensure SDK readiness without blocking the main thread.

💡 Interview Tip

The core insight: "DI provides the wrapper immediately. The wrapper suspends internally until the async SDK is ready. Callers just call sdkHolder.sdk.await() — they don't know or care that the SDK was async." This separates the async concern from the DI concern cleanly.

Mark it done or save it in the question bank · Back to top

Hard

30. Scenario: You want to intercept every network request to add auth headers. How do you implement this cleanly using DI and OkHttp interceptors?

Auth interceptors are a classic DI design problem — the interceptor needs the session token, the session needs the API, and the API needs OkHttp. Circular dependency risk and proper scoping are the challenges.

// AuthInterceptor — gets token from SessionManager
class AuthInterceptor @Inject constructor(
    private val session: dagger.Lazy<SessionManager>  // Lazy breaks circular dep!
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = session.get().getToken()   // Lazy.get() on first use
        val request = if (token != null) {
            chain.request().newBuilder()
                .header("Authorization", "Bearer $token")
                .build()
        } else chain.request()
        return chain.proceed(request)
    }
}

// Network module — wires it all together
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideOkHttp(authInterceptor: AuthInterceptor): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(authInterceptor)
            .build()
    // AuthInterceptor uses Lazy<SessionManager>
    // SessionManager may depend on OkHttpClient
    // Lazy breaks the cycle: OkHttp built first, SessionManager created later on demand
}

// TokenRefresh interceptor — retry 401 with fresh token
class TokenRefreshInterceptor @Inject constructor(
    private val session: dagger.Lazy<SessionManager>
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val response = chain.proceed(chain.request())
        if (response.code == 401) {
            response.close()
            session.get().refreshToken()   // blocking call here is intentional
            return chain.proceed(chain.request())
        }
        return response
    }
}

Wiring an auth interceptor with Hilt creates a classic circular dependency: the OkHttpClient needs an AuthInterceptor, the AuthInterceptor needs a SessionManager to read the token, and the SessionManager may use a Retrofit instance built from that same OkHttpClient to refresh tokens. The solution is dagger.Lazy<SessionManager> in the AuthInterceptor constructor. Hilt builds the OkHttpClient and AuthInterceptor first — the SessionManager is not constructed until interceptor.intercept() is first called, by which point the entire graph including SessionManager is initialised.

The AuthInterceptor should be @Singleton and effectively stateless at the instance level — it reads the current token from the SessionManager on each request rather than caching it. This means a token rotation (after a refresh) is automatically picked up on the next request without recreating the interceptor. For 401 handling and token refresh, a separate TokenRefreshInterceptor is cleaner than putting refresh logic in the auth interceptor. The refresh interceptor calls chain.proceed(request), checks the response code, and if it is 401, closes the response (critical — OkHttp leaks connections if responses are not closed), refreshes the token, rebuilds the request with the new token, and retries.

The @IntoSet multibinding pattern is the scalable approach for managing multiple interceptors. Each interceptor is contributed with @Binds @IntoSet, and the network module receives a Set<@JvmSuppressWildcards Interceptor> which it registers with the OkHttpClient.Builder. Adding a new interceptor — logging, analytics, timeout override — requires only a new @Binds @IntoSet in the relevant feature module without touching the network configuration. Each interceptor is a separate class with a single responsibility, independently unit-testable by constructing it with a fake chain.

💡 Interview Tip

The Lazy trick for circular dependencies: "OkHttpClient needs AuthInterceptor. AuthInterceptor needs SessionManager. SessionManager needs OkHttpClient." Without Lazy, this is a Dagger build error. dagger.Lazy<SessionManager> breaks the cycle — OkHttp builds first, SessionManager is created lazily on first token read.

Mark it done or save it in the question bank · Back to top

Hard

31. Scenario: You need two separate Retrofit instances — one for your own API and one for a third-party API with different base URLs, headers, and timeouts. How do you set this up with Hilt?

Multiple Retrofit instances require qualifiers to distinguish them. The pattern is qualifier annotations + separate @Provides methods — each qualifier represents a different API client configuration.

// Step 1: Define qualifiers for each API
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class MainApi
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class ThirdPartyApi

// Step 2: Provide each OkHttp + Retrofit combo separately
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {

    // Main API OkHttp — with auth interceptor
    @Provides @Singleton @MainApi
    fun provideMainOkHttp(authInterceptor: AuthInterceptor): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor(authInterceptor)
            .connectTimeout(30, TimeUnit.SECONDS)
            .build()

    // Third-party API OkHttp — different timeout, API key header
    @Provides @Singleton @ThirdPartyApi
    fun provideThirdPartyOkHttp(): OkHttpClient =
        OkHttpClient.Builder()
            .addInterceptor { chain ->
                chain.proceed(chain.request().newBuilder()
                    .header("X-API-Key", BuildConfig.THIRD_PARTY_KEY)
                    .build())
            }
            .connectTimeout(10, TimeUnit.SECONDS)
            .build()

    @Provides @Singleton @MainApi
    fun provideMainRetrofit(@MainApi client: OkHttpClient): Retrofit =
        Retrofit.Builder().client(client).baseUrl("https://api.myapp.com").build()

    @Provides @Singleton @ThirdPartyApi
    fun provideThirdPartyRetrofit(@ThirdPartyApi client: OkHttpClient): Retrofit =
        Retrofit.Builder().client(client).baseUrl("https://api.thirdparty.com").build()

    @Provides @Singleton
    fun provideUserApi(@MainApi retrofit: Retrofit): UserApi =
        retrofit.create(UserApi::class.java)

    @Provides @Singleton
    fun provideWeatherApi(@ThirdPartyApi retrofit: Retrofit): WeatherApi =
        retrofit.create(WeatherApi::class.java)
}

When an app communicates with multiple backend APIs — your own backend, a mapping API, a payment processor — each needs a distinct OkHttpClient and Retrofit instance with different base URLs, timeout configurations, and interceptor chains. Custom qualifiers make the disambiguation explicit and compile-time safe. Define @Qualifier annotation class MainApi and @Qualifier annotation class MapsApi, then annotate each @Provides function with the appropriate qualifier: @MainApi @Provides fun provideMainOkHttp(): OkHttpClient and @MainApi @Provides fun provideMainRetrofit(@MainApi okHttp: OkHttpClient): Retrofit.

Qualifier propagation ensures each API stack is self-consistent. The @MainApi qualifier flows from the OkHttpClient through the Retrofit instance. However, the final API service interfaces — UserApi, OrderApi — are typically unqualified because there is only one UserApi binding in the graph. The qualifier resolves the ambiguity at the Retrofit level; downstream interfaces are unambiguous. Only annotate a binding with a qualifier where the same type is provided by multiple bindings — over-qualifying (adding qualifiers to things that do not need them) creates unnecessary complexity.

Separate interceptor stacks are one of the key benefits of multiple clients. The main API client might carry auth headers and retry logic. The maps API client might have no auth but a generous timeout for tile loading. The payment API client might have strict timeout limits and certificate pinning. Sharing a single OkHttpClient across all three forces you to add conditional logic inside interceptors to detect which request is being made — a fragile approach. Separate clients per API surface maintain clean separation of concerns and make each stack independently configurable and testable.

💡 Interview Tip

The qualifier must propagate through the chain: @MainApi OkHttp → @MainApi Retrofit → UserApi (no qualifier needed since there's only one UserApi). The qualifier resolves ambiguity at the OkHttp and Retrofit levels; the concrete API interfaces are unambiguous.

Mark it done or save it in the question bank · Back to top

Hard

32. Scenario: A colleague argues that @Inject on every class makes the codebase "tightly coupled to Hilt." Are they right, and how do you respond?

This is a nuanced technical debate. @Inject is from javax.inject (JSR-330) — a standard annotation not tied to Hilt. But there are legitimate concerns about Hilt-specific annotations leaking into domain code.

// @Inject — NOT Hilt-specific
import javax.inject.Inject   // Standard JSR-330 — supported by Dagger, Hilt, Guice, Koin

class UserRepository @Inject constructor(
    private val api: UserApi,
    private val dao: UserDao
)
// This class can be used with Hilt, Dagger, Guice, or manual DI
// Not Hilt-locked — @Inject is a universal DI standard

// These ARE Hilt-specific and should stay out of domain:
// @HiltViewModel — presentation layer only
// @AndroidEntryPoint — Android framework classes only
// @InstallIn — module configuration, not business classes
// @HiltAndroidApp — Application class only

// Domain layer — zero Hilt annotations (correct)
// domain/src/main/kotlin/PlaceOrderUseCase.kt
class PlaceOrderUseCase @Inject constructor(  // javax.inject.Inject — standard
    private val orderRepo: OrderRepository,
    private val inventoryRepo: InventoryRepository
) {
    // No Hilt imports — only @Inject from javax
    // Testable without Hilt: PlaceOrderUseCase(FakeOrderRepo(), FakeInventoryRepo())
}

// The colleague's concern applies to:
class DomainUseCase @Inject constructor() {
    @Inject lateinit var repo: UserRepository  // ❌ field injection in domain — wrong!
}
// Field injection IS problematic in domain — it's Hilt-triggered, not standard
// Constructor @Inject = fine everywhere
// Field @Inject = only in Android framework classes

The concern about DI annotations in the domain layer requires a distinction between two types of annotations. @Inject is a JSR-330 standard annotation defined in javax.inject — it is not Hilt-specific, not Dagger-specific, and not Android-specific. It works with Dagger, Guice, Koin, Spring, and any other JSR-330 compliant container. Using @Inject constructor on a domain class adds a compile-time dependency on javax.inject, which is a small, stable, framework-neutral library. This is universally accepted in clean architecture discussions.

The annotations that should not appear in the domain layer are Hilt-specific: @HiltViewModel, @AndroidEntryPoint, @InstallIn, @HiltAndroidApp. These express Android-specific component membership and tie the class to the Hilt framework in a way that is not portable. A domain use case annotated with @HiltViewModel would be inappropriate — it conflates a business-logic class with a presentation-framework concern. These annotations belong in the presentation and framework layers, not in the domain.

Testability is unchanged by the presence of @Inject constructor. A use case with @Inject constructor(private val repo: UserRepository) is unit-tested as val useCase = GetUserUseCase(FakeUserRepository()) — the annotation is invisible at the call site and has zero effect on how the class is constructed manually. The common misconception is that @Inject forces framework involvement; it does not. It is documentation for the DI container, ignored completely when calling the constructor directly. Domain classes with @Inject constructors remain pure, portable, and testable without any DI framework in the test environment.

💡 Interview Tip

"Your colleague is partially right — Hilt-specific annotations like @HiltViewModel shouldn't leak into domain code. But @Inject is JSR-330 standard — it's been around since 2009. PlaceOrderUseCase with @Inject constructor is no more 'Hilt-coupled' than a Kotlin data class is 'JVM-coupled'."

Mark it done or save it in the question bank · Back to top

Hard

33. What is the Hilt testing pyramid? How do you test DI bindings at each level?

DI testing has three levels matching the testing pyramid — each validates a different aspect of the dependency graph, from unit tests that don't use Hilt at all to integration tests that validate the full graph.

// LEVEL 1: Unit tests — no Hilt, constructor injection with fakes
class GetUserUseCaseTest {
    private val fakeRepo = FakeUserRepository()
    private val useCase = GetUserUseCase(fakeRepo)  // direct construction

    @Test fun returnsUser() = runTest {
        fakeRepo.setUser(User("1", "Alice"))
        assertEquals("Alice", useCase("1").name)
    }
}

// LEVEL 2: Integration tests — partial Hilt, replace specific modules
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class UserRepositoryIntegrationTest {
    @get:Rule val hiltRule = HiltAndroidRule(this)

    @Inject lateinit var repo: UserRepository

    @Before fun setUp() { hiltRule.inject() }

    @Test fun repoReturnsUser() = runTest {
        // Uses real Room (in-memory) + fake API via @TestInstallIn
        val user = repo.getUser("1")
        assertNotNull(user)
    }
}

// LEVEL 3: Graph validation test — verify the full DI graph compiles correctly
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class HiltGraphValidationTest {
    @get:Rule val hiltRule = HiltAndroidRule(this)

    @Inject lateinit var mainRepo: UserRepository
    @Inject lateinit var analytics: AnalyticsTracker
    @Inject lateinit var session: SessionManager

    @Before fun setUp() { hiltRule.inject() }

    @Test fun allDependenciesProvided() {
        assertNotNull(mainRepo)
        assertNotNull(analytics)
        assertNotNull(session)
        // Just verifying injection succeeded — graph is valid
    }
}
// This test catches: missing bindings, scope mismatches, circular deps
// that only appear at runtime (e.g. when a module is conditionally included)

The Hilt testing pyramid has three levels with distinct purposes and trade-offs. Level one — unit tests — involves no Hilt whatsoever. ViewModels, use cases, repositories, and any class that uses constructor injection are constructed directly with test doubles: val vm = ProfileViewModel(fakeRepo, TestDispatcher()). These tests run on the JVM without an emulator, execute in milliseconds, and constitute the vast majority of a healthy test suite. Hilt is a build-time tool for these classes; at test time they are just Kotlin classes.

Level two — integration tests — uses Hilt with selected overrides. @HiltAndroidTest on the test class sets up the real Hilt component graph, and @TestInstallIn modules replace infrastructure (real network → fake network, real database → in-memory database). These tests verify that multiple real components interact correctly — the repository correctly maps API responses to domain models, the database correctly persists and retrieves data. They run slower than unit tests because they involve Hilt initialisation and often Android infrastructure, but they catch integration bugs that unit tests cannot detect.

Level three — graph validation — is often overlooked. A dedicated instrumented test injects a broad set of dependencies and simply asserts they are non-null: @Inject lateinit var userRepo: UserRepository after hiltRule.inject(). This validates that the entire Hilt component graph is correctly wired. Hilt catches most binding errors at compile time, but dynamic or conditional modules — feature flag-gated modules, build-variant-specific bindings — can escape compile-time validation. A graph validation test catches these at CI time rather than in production. It is cheap to write and provides high confidence that the DI configuration is complete.

💡 Interview Tip

Most Hilt errors are build-time — Dagger validates the graph at compile time. But conditional modules (enabled per flavor or environment) may have valid graph in one config and missing bindings in another. A graph validation test in each test variant catches this early in CI.

Mark it done or save it in the question bank · Back to top

Hard

34. Scenario: Your app's UI tests are very slow because Hilt's full graph is initialised for every test. How do you speed them up?

Full Hilt graph initialisation in every UI test is expensive. Strategic use of test isolation, fake modules, and test scoping significantly reduces setup time.

// Problem: full graph built per test class — slow!
// Each @HiltAndroidTest class creates a new Application with full graph

// Strategy 1: Shared @TestInstallIn modules — apply globally, not per class
// Put in src/test/java/ — applies to ALL test classes automatically
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces   = [NetworkModule::class]
)
@Module
object FakeNetworkModule {
    @Provides @Singleton
    fun provideApi(): UserApi = FakeUserApi()   // instant, no HTTP
}
// Eliminates real network setup time for ALL tests

// Strategy 2: Use TestCoroutineDispatcher — avoids real delays
@TestInstallIn(components = [SingletonComponent::class], replaces = [DispatcherModule::class])
@Module
object TestDispatcherModule {
    @Provides @Singleton @IoDispatcher
    fun provideIo(): CoroutineDispatcher = UnconfinedTestDispatcher()
}

// Strategy 3: Split test classes — avoid one giant test class
// Hilt rebuilds graph per test CLASS, not per test METHOD
// Group tests that share the same module overrides

// Strategy 4: In-memory Room — faster than real SQLite setup
@TestInstallIn(components = [SingletonComponent::class], replaces = [DatabaseModule::class])
@Module
object TestDatabaseModule {
    @Provides @Singleton
    fun provideDb(@ApplicationContext ctx: Context): AppDatabase =
        Room.inMemoryDatabaseBuilder(ctx, AppDatabase::class.java)
            .allowMainThreadQueries()  // allowed in tests
            .build()
}

// Strategy 5: Don't use @HiltAndroidTest for unit-level logic
// Only use it for tests that need the full Activity + Compose stack

Hilt instrumented tests are significantly slower than unit tests because they initialise the full Android component graph. The biggest performance win is replacing all infrastructure modules globally via @TestInstallIn. A single FakeNetworkModule replacing the real network module provides instant, deterministic responses to all network calls in every test — eliminating HTTP round-trip latency, flakiness from real network calls, and the need to run a mock server. Similarly, replacing the dispatcher module with a StandardTestDispatcher means any delay() calls in the production code can be skipped by advancing virtual time, making async tests deterministic and near-instant.

In-memory Room databases are dramatically faster than file-backed SQLite for tests. Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() has no disk I/O setup cost and is automatically cleared between test runs. Providing this in a @TestInstallIn module means every test that involves the database gets a fresh, fast, isolated database without any explicit setup or teardown code. This transforms what might have been slow, order-dependent database tests into fast, isolated ones.

Hilt rebuilds the component graph per test class, not per test method. This means the number of test classes directly affects total test time — each class incurs the graph initialisation overhead. Grouping related test methods into fewer, larger test classes reduces this overhead. The trade-off is slightly reduced isolation: a poorly designed test can affect subsequent tests in the same class if it mutates shared state. The practical recommendation is to group tests by feature or flow rather than by arbitrary splits, using @Before and @After to reset state between methods within a class.

💡 Interview Tip

The biggest win: put FakeNetworkModule + TestDatabaseModule + TestDispatcherModule in src/androidTest/ with @TestInstallIn. They apply to ALL @HiltAndroidTest classes automatically. Real network and disk I/O eliminated — test run time cuts by 50-70%.

Mark it done or save it in the question bank · Back to top

Hard

35. How does Hilt integrate with Jetpack Compose navigation? How do you scope a ViewModel to a NavGraph in multi-module?

Hilt + Compose Navigation integration lets you scope ViewModels to a NavGraph — shared across multiple screens within a flow. This is the correct pattern for multi-step flows like onboarding or checkout.

// Single screen ViewModel — default, scoped to composable destination
@Composable
fun HomeScreen(vm: HomeViewModel = hiltViewModel()) {
    // vm is scoped to the HomeScreen destination
    // Destroyed when navigating away
}

// NavGraph-scoped ViewModel — shared across multiple destinations in a nested graph
// Step 1: Define nested NavGraph
fun NavGraphBuilder.checkoutGraph(navController: NavController) {
    navigation(startDestination = "cart", route = "checkout") {
        composable("cart") { entry ->
            val parentEntry = remember(entry) {
                navController.getBackStackEntry("checkout")  // NavGraph entry
            }
            val checkoutVm: CheckoutViewModel = hiltViewModel(parentEntry)
            CartScreen(checkoutVm)
        }
        composable("payment") { entry ->
            val parentEntry = remember(entry) {
                navController.getBackStackEntry("checkout")
            }
            val checkoutVm: CheckoutViewModel = hiltViewModel(parentEntry)
            // SAME CheckoutViewModel as CartScreen — shared state!
            PaymentScreen(checkoutVm)
        }
    }
}

// In multi-module: CheckoutViewModel in :feature:checkout
@HiltViewModel
class CheckoutViewModel @Inject constructor(
    private val cartRepo: CartRepository,
    private val paymentRepo: PaymentRepository,
    private val saved: SavedStateHandle
) : ViewModel() {
    var selectedAddress by mutableStateOf<Address?>(null)
    var selectedPayment by mutableStateOf<PaymentMethod?>(null)
}
// CheckoutViewModel lives for the entire "checkout" NavGraph lifetime

In Compose Navigation, hiltViewModel() with no arguments scopes the ViewModel to the current navigation destination. The ViewModel is created when the destination enters the back stack and cleared when it is fully popped. This is the correct default for screen-specific state: a product detail screen's ViewModel should not survive when the user navigates back to the list. The ViewModel lifecycle mirrors the destination lifecycle, which in practice means it survives recompositions, configuration changes, and temporary navigations to child destinations — it is cleared only when the destination itself is removed from the back stack.

Sharing a ViewModel across multiple destinations within a NavGraph requires scoping it to the graph rather than to individual destinations. The API is hiltViewModel(navController.getBackStackEntry("checkout_graph")) where "checkout_graph" is the route of the parent navigation() block. This retrieves the back stack entry for the graph itself, and the ViewModel is scoped to that entry's lifecycle. It is created when any destination within the graph first requests it and cleared when the user navigates completely out of the graph. All destinations within the graph share the same instance.

The remember(entry) wrapping is a performance detail: getBackStackEntry involves a back stack lookup on every recomposition, which is unnecessary overhead. Wrapping it in val entry = remember(navController) { navController.getBackStackEntry("checkout_graph") } performs the lookup once and caches the result, recomputing only if the navController reference changes. In multi-module architectures, this pattern enables a ViewModel defined in :feature:checkout to be shared between a CheckoutCartScreen and a CheckoutPaymentScreen — both request the same ViewModel type from the same graph entry and receive the same instance.

💡 Interview Tip

hiltViewModel(parentEntry) is the answer to "how do I share state between onboarding/checkout steps?" The ViewModel lives as long as the NavGraph, not the individual screen. When the user completes or backs out of the flow, the ViewModel is destroyed and its resources are released.

Mark it done or save it in the question bank · Back to top

Hard

36. Scenario: You discover your app has a @Singleton dependency that holds a large in-memory cache growing unboundedly. How do you fix this with DI?

Unbounded @Singleton caches are a classic memory problem. The DI solution involves scope narrowing, eviction policies, and WeakReference strategies — each with different trade-offs.

// Problem: @Singleton cache that grows forever
@Singleton
class ImageCache @Inject constructor() {
    private val cache = mutableMapOf<String, Bitmap>()  // ❌ grows forever!
    fun put(key: String, bitmap: Bitmap) { cache[key] = bitmap }
}

// Fix 1: LRU cache with eviction policy
@Singleton
class ImageCache @Inject constructor() {
    private val maxMemory = Runtime.getRuntime().maxMemory() / 8
    private val cache = object : LruCache<String, Bitmap>(maxMemory.toInt()) {
        override fun sizeOf(key: String, value: Bitmap) = value.byteCount
    }
}

// Fix 2: Narrow scope — ActivityRetainedScoped instead of Singleton
// Cache per Activity instance — cleared on Activity finish
@Module @InstallIn(ActivityRetainedComponent::class)
object CacheModule {
    @Provides @ActivityRetainedScoped
    fun provideImageCache(): ImageCache = ImageCache()
}
// Cache cleared when user navigates away from the Activity

// Fix 3: Delegate to Coil/Glide — use a battle-tested library cache
@Provides @Singleton
fun provideImageLoader(@ApplicationContext ctx: Context): ImageLoader =
    ImageLoader.Builder(ctx)
        .memoryCache { MemoryCache.Builder(ctx).maxSizePercent(0.25).build() }
        .build()
// Coil manages its own cache with proper eviction

// Fix 4: WeakReference values — GC can evict when memory pressure
private val cache = WeakHashMap<String, WeakReference<Bitmap>>()

Combining DI with in-memory caches requires careful scope selection to prevent memory leaks. A @Singleton-scoped cache lives for the entire application lifetime — correct for content that should persist across screens and sessions, but dangerous if that content references Activity or View objects. An @ActivityRetainedScoped cache lives across configuration changes but is cleared when the user leaves the Activity entirely — appropriate for caches that are conceptually per-session. The scope defines the cache's retention policy, so choosing it thoughtfully is equivalent to defining the caching strategy.

LruCache<K, V> is the right data structure for bounded in-memory caches: it evicts the least-recently-used entries when the cache reaches its size limit, preventing unbounded growth. For image or bitmap caches, prefer delegating entirely to Coil or Glide — these libraries implement multi-level caching (memory and disk), memory pressure handling through ComponentCallbacks2, and lifecycle-aware cache clearing. Reinventing this is rarely necessary and usually produces a less correct implementation. The DI role here is simply providing the ImageLoader (Coil's central caching object) as a singleton to all injection points.

WeakReference wrapping cache values allows the garbage collector to reclaim them under memory pressure even while the cache holds a reference — the cache entry becomes null rather than being explicitly evicted. This is appropriate for caches of objects that are expensive to create but whose absence is recoverable (they can be re-fetched or re-computed). Diagnosing cache-related memory issues uses the Android Studio Memory Profiler: trigger a heap dump and examine the largest retained objects. An unexpectedly large HashMap or LruCache with many entries typically points to a scope that is broader than needed or a cache that is never cleared.

💡 Interview Tip

The DI insight: changing from @Singleton to @ActivityRetainedScoped in ONE @Provides method fixes the memory leak — no other code changes needed. This is DI's power: scope management is centralised. In a non-DI codebase you'd have to hunt through 20 files to find all usages.

Mark it done or save it in the question bank · Back to top

Hard

37. Scenario: You need to provide different database configurations (in-memory for tests, WAL mode for production). How do you architect this with Hilt?

Database configuration is a perfect use case for Hilt's source-set module pattern combined with build type configuration — test gets in-memory, release gets WAL mode with encryption.

// Interface for database configuration
interface DatabaseConfig {
    val useInMemory: Boolean
    val enableWalMode: Boolean
    val encryptionKey: String?
}

// src/main/java/DatabaseModule.kt — shared builder logic
@Module @InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides @Singleton
    fun provideDatabase(
        @ApplicationContext ctx: Context,
        config: DatabaseConfig           // injected — bound per build type
    ): AppDatabase {
        val builder = if (config.useInMemory)
            Room.inMemoryDatabaseBuilder(ctx, AppDatabase::class.java)
        else
            Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db")

        if (config.enableWalMode) builder.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING)
        config.encryptionKey?.let { builder.openHelperFactory(SupportFactory(it.toByteArray())) }

        return builder.build()
    }
}

// src/release/java/DatabaseConfigModule.kt
@Module @InstallIn(SingletonComponent::class)
object DatabaseConfigModule {
    @Provides @Singleton
    fun provideConfig(): DatabaseConfig = object : DatabaseConfig {
        override val useInMemory = false
        override val enableWalMode = true
        override val encryptionKey = BuildConfig.DB_ENCRYPTION_KEY
    }
}

// src/androidTest/java/DatabaseConfigModule.kt
@TestInstallIn(components = [SingletonComponent::class], replaces = [DatabaseConfigModule::class])
@Module
object DatabaseConfigModule {
    @Provides
    fun provideConfig(): DatabaseConfig = object : DatabaseConfig {
        override val useInMemory = true
        override val enableWalMode = false
        override val encryptionKey = null
    }
}

The pattern for environment-specific database configuration separates the configuration policy from the construction logic. Define a DatabaseConfig interface with properties like val encryptionKey: String?, val journalMode: JournalMode, and val inMemory: Boolean. Provide different implementations per build variant via source-set modules: the release implementation returns a key from BuildConfig, enables WAL journal mode for concurrent read performance, and uses file-backed storage. The test implementation sets inMemory = true and returns no encryption key. The DatabaseModule itself never changes — it injects DatabaseConfig and builds the database accordingly.

Write-Ahead Logging (WAL) mode is worth enabling in production Room databases. SQLite's default journal mode (DELETE) locks the database during writes, blocking concurrent reads. WAL allows readers and writers to operate concurrently in most cases, significantly improving performance in apps that read frequently while background syncs write. Enable it with .setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) on the RoomDatabase.Builder. This is a production-only configuration — tests use in-memory databases where journal mode is irrelevant.

Encryption keys must never be hardcoded in source files — they would be visible in version control history and potentially in decompiled APKs. The correct approach stores the key in local.properties (gitignored), reads it into BuildConfig via Gradle, and accesses it at runtime through the injected DatabaseConfig. For higher security, generate and store the key in Android Keystore rather than a BuildConfig field — the key never leaves the secure enclave and is tied to the device. The DI pattern remains the same: DatabaseConfig provides the key, regardless of where the key ultimately comes from.

💡 Interview Tip

The elegant part: DatabaseModule is identical in all variants. Only DatabaseConfig changes. This keeps the complex Room builder code in one place while allowing full environment-specific configuration. Adding a new configuration option = add to the interface + update the two concrete implementations.

Mark it done or save it in the question bank · Back to top

Hard

38. What is the difference between @Singleton in Hilt vs the Singleton design pattern? What are the pitfalls of each?

Hilt's @Singleton and the classic Singleton pattern both ensure one instance, but differ fundamentally in how the instance is created, shared, and replaced. Hilt's approach is superior for testability and maintainability.

// Classic Singleton pattern — global static instance
object UserRepositoryInstance {
    private val instance = UserRepositoryImpl(RetrofitApi(), RoomDatabase())

    // Problems:
    // 1. Hard to test — can't replace with fake
    // 2. Hidden dependencies — creates its own Retrofit and Room
    // 3. Lives forever — never garbage collected
    // 4. Thread safety — object initialisation may have races
    // 5. No lifecycle — no way to "reset" between tests
}

// Hilt @Singleton — managed single instance
@Module @InstallIn(SingletonComponent::class)
abstract class RepoModule {
    @Binds @Singleton
    abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository
}

// Hilt @Singleton advantages:
// ✅ Testable: @TestInstallIn replaces with fake
// ✅ Explicit deps: constructor shows all dependencies
// ✅ Lifecycle: destroyed when Application is killed
// ✅ Thread safe: Dagger double-checked locking under the hood
// ✅ Interface: binds to interface, not concrete class

// Common pitfall: @Singleton when you want @ActivityRetainedScoped
// Result: state leaks between user sessions if app not fully restarted
@Singleton   // ❌ should be @ActivityRetainedScoped for per-user state
class UserPreferencesCache @Inject constructor() {
    var cachedPrefs: UserPrefs? = null  // previous user's prefs still cached!
}

// Rule: @Singleton only for truly stateless services or app-level resources
// Anything with user state → @ActivityRetainedScoped or narrower

The classic singleton pattern — a companion object with a static getInstance() method — has well-known problems. Its dependencies are hidden inside the implementation, constructed internally and never swappable. Testing requires either touching global state or accepting the real implementation in every test that exercises any code that calls the singleton. Lifecycle management is non-existent: the singleton lives from first use until process death, regardless of whether it should logically be cleared when the user logs out. And the singleton exposes its concrete type, coupling all callers to the implementation.

A Hilt @Singleton-scoped binding addresses all of these. Dependencies are declared in the constructor, visible to callers and to the DI framework. Tests replace the binding via @TestInstallIn without touching any global state. The lifetime is tied to the SingletonComponent, which is the Application's lifecycle — long-lived but defined. The binding targets an interface, so callers depend on the abstraction. Thread safety is handled automatically: Hilt generates double-checked locking in the produced factory, ensuring the first .get() from a concurrent context initialises the instance exactly once without requiring manual synchronisation in your code.

The most important pitfall for @Singleton-scoped bindings is storing user-specific state. A @Singleton that holds the current user's profile, preferences, or session tokens will persist across user sessions in apps that support account switching or logout. When user A logs out and user B logs in, the singleton's user A data is still present. The solution is either to scope such state to a user-session scope (custom Hilt scope) or to design the singleton as a stateless accessor that reads from a clearly user-scoped store like DataStore, which is reset on logout. Any @Singleton that you find yourself clearing on logout is a signal it should not be @Singleton.

💡 Interview Tip

The state leak is a real production bug: user A logs out, user B logs in — @Singleton UserPreferencesCache still holds user A's prefs. Fix: move user-specific state to @ActivityRetainedScoped and clear it on logout. DI makes this a one-line scope change.

Mark it done or save it in the question bank · Back to top

Hard

39. Scenario: You're leading a DI design review. The team has these patterns — which do you approve, which do you reject, and why?

A DI design review tests whether you can systematically identify anti-patterns, explain the consequences, and propose clean alternatives — the senior developer lens.

// Pattern A: @Singleton holding a coroutine scope
@Singleton
class DataSyncService @Inject constructor() {
    private val scope = CoroutineScope(Dispatchers.IO)  // ❌ never cancelled!
}
// REJECT: scope leaks coroutines. Fix: inject viewModelScope or use GlobalScope with care
// Better: CoroutineScope(SupervisorJob() + Dispatchers.IO) cancelled in Application.onTerminate

// Pattern B: ViewModel directly constructing UseCases
@HiltViewModel
class UserViewModel @Inject constructor(private val repo: UserRepository) : ViewModel() {
    private val getUser = GetUserUseCase(repo)  // ❌ VM creating its own deps
}
// REJECT: use cases should be injected. Prevents mocking/testing use case independently
// Fix: @HiltViewModel constructor(private val getUser: GetUserUseCase)

// Pattern C: @Provides that catches and swallows exceptions
@Provides @Singleton
fun provideAnalytics(): Analytics? = try {
    Firebase.analytics
} catch (e: Exception) { null }   // ❌ silent failure!
// REJECT: injection failure should crash loudly — never silently return null from @Provides
// Fix: use no-op pattern (NoOpAnalytics) or let it throw to surface the error early

// Pattern D: constructor injection everywhere in domain and data
class PlaceOrderUseCase @Inject constructor(private val repo: OrderRepository) { }
class OrderRepositoryImpl @Inject constructor(private val api: OrderApi) : OrderRepository
// ✅ APPROVE: clean, testable, standard pattern

// Pattern E: @Singleton object with companion that does complex initialisation
@Module @InstallIn(SingletonComponent::class)
object SecurityModule {
    @Provides @Singleton
    fun provideKeyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
}
// ✅ APPROVE: complex initialisation belongs in @Provides, not in field init

Code review pattern analysis for common Hilt bugs. Pattern A — a @Singleton that creates a CoroutineScope(Dispatchers.IO) internally and never cancels it — should be rejected. The scope leaks coroutines launched within it if the work is fire-and-forget without cancellation. The correct approach is to inject a @ApplicationScope CoroutineScope that is provided by a module and tied to the Application lifecycle. This makes the scope's lifetime explicit, injectable, and testable. Background work launched in a singleton should use an externally-provided scope that the application can cancel on shutdown.

Pattern B — a ViewModel calling GetUserUseCase(userRepository) directly inside an init block, constructing the use case manually — bypasses DI entirely. The ViewModel has become responsible for constructing its own dependencies, which is exactly what DI is designed to prevent. The use case's own dependencies (and their dependencies) must be threaded through the ViewModel's constructor, and in tests the ViewModel cannot receive a fake use case. The fix is to inject GetUserUseCase via the ViewModel's constructor and let Hilt construct it. Pattern C — returning null from a @Provides method and injecting T? — silently hides initialisation failures and forces null checks at every injection site. Pattern D (constructor injection, testable) and Pattern E (complex init in @Provides) are the correct approaches.

The underlying review heuristic: any class that constructs another class internally (rather than receiving it) has taken on a responsibility that belongs to the DI framework. Any @Provides method returning null is a smell — use the No-op pattern instead. Any coroutine scope created without an explicit cancellation strategy in a long-lived class is a memory and resource leak. These patterns are common in codebases transitioning from manual construction to DI, where old habits persist in new code. Code review is the right place to catch them before they accumulate.

💡 Interview Tip

Design reviews test pattern recognition. Walk through each systematically: "A — scope leak. B — VM creating its own deps breaks DI. C — silent failure hides bugs. D — correct. E — correct." Explaining the consequence (not just 'wrong') shows why the pattern matters in production.

Mark it done or save it in the question bank · Back to top

Hard

40. How does Hilt handle injection into non-standard Android classes like BroadcastReceiver, ContentProvider, and Service?

BroadcastReceiver, ContentProvider, and Service each have unique lifecycle quirks — particularly ContentProvider which initialises before the Hilt graph. Each requires a different approach.

// Service — @AndroidEntryPoint works normally
@AndroidEntryPoint
class SyncService : Service() {
    @Inject lateinit var syncRepo: SyncRepository  // ✅ injected in onCreate
    override fun onCreate() { super.onCreate() }  // injection happens here
}

// BroadcastReceiver — @AndroidEntryPoint but STATELESS
// CRITICAL: BroadcastReceiver is NOT guaranteed to live long enough for async work
@AndroidEntryPoint
class ConnectivityReceiver : BroadcastReceiver() {
    @Inject lateinit var syncManager: SyncManager  // ✅ but must use goAsync()

    override fun onReceive(ctx: Context, intent: Intent) {
        val pending = goAsync()  // extend lifecycle for async work
        CoroutineScope(Dispatchers.IO).launch {
            try {
                syncManager.scheduleSync()
            } finally {
                pending.finish()  // must call finish() or receiver is killed
            }
        }
    }
}

// ContentProvider — CANNOT use @AndroidEntryPoint!
// ContentProvider.onCreate() runs BEFORE Application.onCreate()
// → Hilt graph doesn't exist yet → injection fails
class UserContentProvider : ContentProvider() {
    // ❌ @AndroidEntryPoint here = NPE on injection

    // ✅ Use @EntryPoint instead (lazy access after graph is ready)
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface ProviderEntryPoint {
        fun userRepository(): UserRepository
    }

    private val repo by lazy {
        EntryPointAccessors.fromApplication(context!!.applicationContext, ProviderEntryPoint::class.java).userRepository()
    }
}

@AndroidEntryPoint works correctly on Service subclasses. Injection occurs in onCreate(), which the framework calls before any service method. Declare @Inject lateinit var fields and they will be populated by the time onCreate() body executes. For IntentService or JobIntentService alternatives, use CoroutineWorker with @HiltWorker instead — these integrate better with the modern Android background processing model.

BroadcastReceiver supports @AndroidEntryPoint but has a critical timing constraint: onReceive() is called on the main thread and must return quickly. If you need to perform async work in response to a broadcast — writing to a database, making a network call — you must call goAsync() before starting any async work, which returns a PendingResult that you must call .finish() on when the work completes. Without goAsync(), Android may kill the process before async work completes. BroadcastReceiver is also stateless by contract — Android creates a new instance for each broadcast, so injected fields are re-populated on every call and no state should be stored between broadcasts.

ContentProvider is the exception: @AndroidEntryPoint does not work because Android can initialise a ContentProvider before Application.onCreate() runs — before Hilt has built its component graph. Attempting to inject at construction time references a component that does not yet exist. The correct approach is @EntryPoint with lazy access: define an entry point interface, and inside every query/insert/update/delete method retrieve dependencies via EntryPointAccessors.fromApplication(). By the time a content provider serves its first request, Application.onCreate() has completed and the Hilt graph is available.

💡 Interview Tip

ContentProvider is the most important edge case: "ContentProvider.onCreate() runs before Application.onCreate() — Hilt doesn't exist yet. @AndroidEntryPoint will crash. Use @EntryPoint with lazy access instead — by the time first query() is called, the Application has initialised Hilt."

Mark it done or save it in the question bank · Back to top

Hard

41. Scenario: Your team is adopting Kotlin Multiplatform. You have Hilt in the Android app. How do you share the business logic while keeping DI working?

KMP and Hilt coexist when you keep Hilt strictly in the Android platform layer. Shared business logic uses constructor injection with standard interfaces — no Hilt annotations in commonMain.

// KMP project structure
// :shared
//   ├── commonMain — shared business logic
//   ├── androidMain — Android-specific implementations
//   └── iosMain — iOS-specific implementations
// :androidApp — Android entry point (uses Hilt)

// commonMain — NO Hilt, NO android.* imports
// Uses constructor injection only (javax.inject or custom)
interface UserRepository {         // interface in commonMain
    suspend fun getUser(id: String): User
}

class GetUserUseCase(                // no @Inject — just constructor
    private val repo: UserRepository
) {
    suspend operator fun invoke(id: String) = repo.getUser(id)
}

// androidMain — Android implementation
class AndroidUserRepository @Inject constructor(  // @Inject OK in androidMain
    private val api: UserApi,
    private val dao: UserDao
) : UserRepository {
    override suspend fun getUser(id: String) = api.getUser(id)
}

// :androidApp — Hilt wiring
@Module @InstallIn(SingletonComponent::class)
abstract class SharedModule {
    @Binds @Singleton
    abstract fun bindRepo(impl: AndroidUserRepository): UserRepository

    companion object {
        @Provides @Singleton
        fun provideUseCase(repo: UserRepository): GetUserUseCase = GetUserUseCase(repo)
    }
}

// iOS: Koin (supports KMP) or manual DI
// val repo = IosUserRepository()
// val getUser = GetUserUseCase(repo)

Hilt is Android-only — it depends on Android Gradle Plugin, android.app.Application, and Android-specific annotation processing. It cannot be used in commonMain source sets that compile to iOS, JVM server, or other targets. The correct architecture for KMP projects uses pure constructor injection in commonMain: interfaces are declared in shared code with no DI annotations, and platform-specific implementations live in platform source sets. The shared business logic — use cases, domain models, repositories-as-interfaces — remains framework-agnostic.

In the androidMain source set, implementations can use @Inject constructor because JSR-330 is available on Android. The Hilt wiring happens in the :androidApp module: @Module classes bind the androidMain implementations to the commonMain interfaces. Shared use cases that live in commonMain and cannot carry @Inject (because the annotation may not be available in common Kotlin) are provided via explicit @Provides functions in the Android module: @Provides fun provideGetUserUseCase(repo: UserRepository): GetUserUseCase = GetUserUseCase(repo).

On iOS, Koin is the practical choice for shared DI because it is written in pure Kotlin and has first-class KMP support — modules, scopes, and injection work identically across Android and iOS. Some teams use manual DI on iOS (a Swift equivalent of an AppContainer) and only use Koin for the shared Kotlin layer accessed from Swift. The important architectural principle is that each platform owns its own DI wiring: Android uses Hilt (or Koin) for Android-specific dependencies and wires shared dependencies through modules, while iOS wires the same shared classes through its own mechanism. The shared code is DI-framework-agnostic, ensuring portability.

💡 Interview Tip

The architecture rule: "Hilt is an Android-only framework — it lives in :androidApp and androidMain only. commonMain knows nothing about Hilt, just constructor injection. The shared business logic is portable; Android wiring is platform-specific." This is the separation that makes KMP work.

Mark it done or save it in the question bank · Back to top

Hard

42. What are the Hilt updates and improvements in 2024-25? How do they affect your DI patterns?

2024-25 brought significant Hilt improvements — KSP stability, assisted injection improvements, Compose integration, and new testing APIs that simplify test setup considerably.

// 1. KSP full support (stable since 2024)
// All Hilt annotations now work with KSP — KAPT deprecated
// build.gradle.kts:
plugins { id("com.google.devtools.ksp") }
dependencies {
    ksp("com.google.dagger:hilt-compiler:2.51")  // not kapt!
}

// 2. hiltViewModel with creationCallback (Hilt 2.49+)
// Replaces the manual parentEntry pattern for assisted injection
@Composable
fun ProductScreen(productId: String) {
    val vm: ProductViewModel = hiltViewModel(
        creationCallback = { factory: ProductViewModel.Factory ->
            factory.create(productId)   // cleaner than NavBackStackEntry lookup
        }
    )
}

// 3. @HiltViewModel with @AssistedInject — fully supported
@HiltViewModel(assistedFactory = ProductViewModel.Factory::class)
class ProductViewModel @AssistedInject constructor(
    @Assisted val productId: String,
    private val repo: ProductRepository
) : ViewModel() {
    @AssistedFactory
    interface Factory { fun create(productId: String): ProductViewModel }
}

// 4. Improved test APIs — testImplementation("com.google.dagger:hilt-android-testing:2.51")
// HiltAndroidRule improvements — more granular control
// @BindValue — inject test doubles inline without @TestInstallIn
@HiltAndroidTest
class UserTest {
    @get:Rule val hiltRule = HiltAndroidRule(this)

    @BindValue val fakeRepo: UserRepository = FakeUserRepository()
    // No separate @TestInstallIn module needed!
}

KSP support for Hilt moved to stable in Hilt 2.48 and KAPT support is now officially deprecated. Migrating is straightforward: replace kapt("com.google.dagger:hilt-android-compiler:...") with ksp("com.google.dagger:hilt-android-compiler:...") in every module's build.gradle.kts and apply the KSP Gradle plugin instead of KAPT. The migration is purely a build-script change — no production code changes are required. The build time improvement is immediate and significant, typically 30–50% on clean builds, with larger gains on incremental builds when combined with ksp.incremental=true.

Assisted injection for @HiltViewModel received a first-class API. Previously, getting a ViewModel with runtime parameters required manually obtaining the factory from navBackStackEntry and calling it with the parameter. Hilt 2.49 introduced hiltViewModel(creationCallback = { factory: ProductViewModel.Factory -> factory.create(productId) }), which handles the factory lookup and invocation declaratively. Hilt 2.51 further formalises this with @HiltViewModel(assistedFactory = ProductViewModel.Factory::class), letting Hilt verify the factory type at compile time rather than relying on the caller to cast correctly.

@BindValue is a testing annotation that significantly reduces the need for @TestInstallIn for simple test double scenarios. In a @HiltAndroidTest class, annotating a field with @BindValue @JvmField val repo: UserRepository = FakeUserRepository() tells Hilt to use that field's value as the binding for UserRepository in the test's component — no separate test module required. This is particularly useful for injecting specific test doubles or pre-configured fakes that vary per test class. Complex module-level overrides still use @TestInstallIn, but @BindValue eliminates the boilerplate for straightforward substitutions.

💡 Interview Tip

"The biggest practical changes: KSP is now fully stable (migrate from KAPT immediately — it's faster), and @BindValue simplifies test setup dramatically. Instead of a separate @TestInstallIn module for each test, you can declare fake bindings inline in the test class." Applied knowledge of recent updates signals you're current.

Mark it done or save it in the question bank · Back to top

Hard

43. Scenario: You're doing a DI health check on a legacy codebase. What red flags do you look for and what tools do you use?

A DI health check is a systematic audit of common anti-patterns. Each red flag maps to a specific risk — memory leaks, testability gaps, or architectural violations.

// RED FLAGS TO SEARCH FOR:

// 1. GlobalScope in @Singleton classes
// grep -r "GlobalScope" app/src/main
@Singleton class X @Inject constructor() {
    val scope = GlobalScope  // ❌ leaks beyond Application lifecycle
}

// 2. Activity/Context stored in @Singleton
// grep -r "private val context: Activity" — when injected into @Singleton
@Singleton class Y(private val ctx: Activity)  // ❌ memory leak

// 3. Missing @Singleton on expensive objects
// Look for: @Provides fun provideRetrofit() — without @Singleton
@Provides fun provideRetrofit(): Retrofit  // ❌ new instance per injection

// 4. @Inject field in non-framework classes
// grep -r "@Inject" --include="*Repository.kt"
class Repo { @Inject lateinit var api: Api }  // ❌ field injection in non-Android class

// 5. @InstallIn(SingletonComponent) with @ActivityScoped annotation
@Provides @ActivityScoped  // ❌ scope mismatch — build error but catch in review
fun provideX(): X

// 6. Circular references via non-Lazy
// detekt rule: forbid circular patterns

// TOOLS:
// gradle :app:kspDebugKotlin — runs Hilt validation, shows binding errors
// detekt — custom rules for DI anti-patterns
// LeakCanary — detects Context/Activity memory leaks at runtime
// Android Memory Profiler — heap dumps for unbounded @Singleton caches
// grep / IDE Find Usages — search for known anti-patterns manually

The most impactful production DI issues fall into predictable categories. Memory leaks from Activity context in singletons are the most common: a @Singleton that stores context: Context without the @ApplicationContext qualifier receives the Activity context and holds it alive through rotations. LeakCanary detects this as a chain: SingletonService → context → MainActivity (destroyed). The fix is always @ApplicationContext on any context injection in a long-lived scope. Grep for bare context: Context parameters in @Singleton-scoped bindings as a preemptive code review step.

Multiple instances of heavyweight objects — Retrofit, Room, OkHttpClient — are a silent performance issue that does not cause crashes but wastes memory and creates inconsistent state. Each missing @Singleton on a heavy binding means a new instance per injection. The Android Studio Memory Profiler's heap dump reveals this: multiple instances of the same type where only one was expected. Code review catches it before deployment: every @Provides function returning Retrofit, AppDatabase, or OkHttpClient should have @Singleton. Missing this annotation is the most common DI mistake in production codebases.

Field injection in classes that are not Android framework types (Activity, Fragment, Service) is a coupling problem rather than a runtime failure. A use case or repository using @Inject lateinit var instead of constructor injection requires Hilt to be present and configured to populate the field — it cannot be used in non-Android tests, and the injection may not have occurred when methods are first called. Grep for @Inject on var declarations in non-Activity/Fragment/Service classes to identify these. Scope mismatches that Hilt catches at compile time can still be caught earlier in code review, preventing the build from breaking in CI — reviewing module additions for scope coherence takes seconds and saves a build cycle.

💡 Interview Tip

Start with automated tooling: "I run LeakCanary in debug builds permanently — it catches Context leaks immediately. Then I do a targeted grep for GlobalScope, Activity context in @Singleton, and missing @Singleton on Retrofit/OkHttp. Finally a detekt rule for field injection outside Android components."

Mark it done or save it in the question bank · Back to top

Hard

44. Scenario: You're architecting DI for a 10-module app from scratch. Walk through every decision you'd make.

A complete DI architecture walkthrough demonstrates the full application of every concept — from dependency on KSP to scope decisions to testing strategy. This is the senior system-design answer.

// Complete DI architecture for a 10-module app

// STEP 1: Build setup — every module
// Convention plugin: myapp.android.feature
plugins { id("com.google.devtools.ksp") }  // KSP, not KAPT
plugins { id("dagger.hilt.android.plugin") }
dependencies {
    implementation("com.google.dagger:hilt-android:2.51")
    ksp("com.google.dagger:hilt-compiler:2.51")
}

// STEP 2: Module structure for @Modules
// :core:network  → NetworkModule (@Singleton: OkHttp, Retrofit, APIs)
// :core:database → DatabaseModule (@Singleton: Room, DAOs)
// :core:session  → SessionModule (@Singleton: SessionManager interface)
// :feature:*     → each has its own module binding its repositories

// STEP 3: Scope decisions
// @Singleton:            OkHttp, Retrofit, Room, shared repositories
// @ActivityRetainedScoped: per-Activity caches, user session objects
// @ViewModelScoped:      stateful per-VM repositories
// Unscoped (default):    stateless use cases, formatters, validators

// STEP 4: Qualifier strategy
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class MainDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class MainApi  // if multiple APIs

// STEP 5: Testing infrastructure
// src/androidTest/ global replacements:
// @TestInstallIn: FakeNetworkModule, TestDatabaseModule, TestDispatcherModule
// src/test/kotlin: FakeUserRepository, FakeOrderRepository in :core:testing

// STEP 6: Source set modules for build variants
// src/debug/ : DebugModule (LogAnalytics, no-op crash reporter)
// src/release/: ProductionModule (Firebase analytics, Crashlytics)

// STEP 7: Hilt version catalog entries
// [versions] hilt = "2.51"
// [libraries] hilt-android = { module = "dagger:hilt-android", version.ref = "hilt" }
// [plugins] hilt = { id = "dagger.hilt.android.plugin", version.ref = "hilt" }

// Decision: KAPT or KSP?
// → KSP always. KAPT deprecated, KSP 2x faster, fully supported in 2024

Convention plugins are the most impactful Hilt scaling practice. In a project with 10 feature modules, adding Hilt manually to each requires applying the same Gradle plugin, adding the same dependencies, and configuring KSP identically in every module. A convention plugin — com.myapp.hilt — encapsulates all of this in one file. Each feature module's build.gradle.kts becomes a one-liner: plugins { id("com.myapp.hilt") }. When the Hilt version is updated or KSP configuration changes, the convention plugin is the single change point. Consistency across modules is enforced without coordination overhead.

Module boundary discipline prevents the common anti-pattern of everything funnelling through :app. Each :core module owns its @Module classes: :core:network owns NetworkModule, :core:database owns DatabaseModule, :feature:profile owns ProfileModule. The :app module's role is minimal — it contains @HiltAndroidApp and wires top-level navigation, but no binding declarations. Hilt discovers all modules automatically from the classpath, so :app does not need to import or reference feature modules' DI configuration. This enables teams to work independently on feature modules without DI conflicts.

Scope strategy should be a deliberate team decision rather than individual choice. The default should be unscoped — create a new instance per injection — and scope only when there is a clear reason: shared mutable state, expensive construction, or explicit lifecycle requirements. Document scope decisions in the module with a comment explaining why the scope was chosen. Global test modules in androidTest/ that use @TestInstallIn apply to all instrumented tests without per-test configuration — define the project's test infrastructure (fake network, in-memory database, test dispatchers) there and let individual test classes focus on their specific scenarios.

💡 Interview Tip

Walk through decisions in this order: "1) KSP not KAPT. 2) Convention plugin for consistent setup. 3) Module per :core module, auto-discovered by Hilt. 4) Scope decisions driven by lifetime, not by convenience. 5) Global fake modules in androidTest. 6) Source sets for variants." This structured walkthrough shows system-design maturity.

Mark it done or save it in the question bank · Back to top

Hard

45. Scenario: Your Hilt build fails with "cannot be provided without an @Provides-annotated method." How do you systematically diagnose and fix this error?

'Cannot be provided without an @Provides-annotated method' means Hilt can't find a way to create the type you're requesting. The three root causes: missing @Inject constructor, missing @Provides method in a module, or the type is provided in a different component scope than where you're injecting it.

// Cause 1: missing @Inject on the class constructor
class UserRepository /* missing @Inject */ constructor(
    private val dao: UserDao
)
// Fix:
class UserRepository @Inject constructor(private val dao: UserDao)

// Cause 2: interface -- Hilt does not know which implementation to use
@Module @InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds abstract fun bindUserRepo(impl: UserRepositoryImpl): UserRepository
}

// Cause 3: wrong component scope -- ViewModelComponent cannot access ActivityComponent bindings
// Fix: move the binding to SingletonComponent or the correct scope

Hilt build errors always name the missing type — start there. "Cannot be provided without an @Inject constructor or an @Provides-annotated method" means exactly one of two things: the class has no @Inject constructor and no @Provides method, or there is a @Provides method but it is in a module that is not installed in the component where it is needed. Search the codebase for the missing type name, find every place it is declared, and verify: either add @Inject to its constructor, or add a @Provides method in a module installed in the correct component with @InstallIn.

Interface binding errors are the second most common Hilt build error. Hilt cannot automatically pick an implementation for an interface — there may be multiple implementations, or none annotated with @Inject. The fix is always a @Binds method in an abstract module: @Binds abstract fun bindUserRepo(impl: UserRepositoryImpl): UserRepository. This tells Hilt which concrete class to provide when UserRepository is requested. Forgetting the module's @InstallIn annotation is a close variant of this error: the module exists but is not attached to any component, making its bindings invisible to the graph.

Scope mismatch errors are the most instructive because they reveal architectural issues. "Cannot inject a scoped binding from a component that does not include that scope" means a binding is declared with a scope that does not exist in the component that is requesting it. The component hierarchy is linear: SingletonComponent → ActivityRetainedComponent → ViewModelComponent → ActivityComponent → FragmentComponent. An @ActivityScoped binding cannot be injected into a class in ViewModelComponent because ViewModelComponent is a sibling of ActivityComponent, not a child. The fix is either to change the scope to something in the ancestry of the requesting component, or to redesign so the dependency flows in the correct direction.

💡 Interview Tip

Read the error bottom-up: "Component → class that needs it → dependency chain → what's missing at the top." Then check: (1) @Inject constructor, (2) @Provides method, (3) @Binds for interfaces, (4) @InstallIn in correct component, (5) correct classpath in multi-module. Five checks, in that order, solves 95% of MissingBinding errors.

Mark it done or save it in the question bank · Back to top

Hard

46. How does Hilt handle injection in Jetpack Compose — what is the difference between hiltViewModel() and viewModel() and when should you use each?

hiltViewModel() uses Hilt's ViewModelFactory, while viewModel() uses the default factory. Understanding when to use each affects both functionality and testability of Compose screens.

// hiltViewModel() — uses Hilt's generated factory
// Requires: @HiltViewModel + @Inject constructor on ViewModel
// Requires: @AndroidEntryPoint on the Activity hosting the NavHost
@Composable
fun HomeScreen(
    vm: HomeViewModel = hiltViewModel()  // Hilt injects all constructor deps
) {
    // HomeViewModel is correctly scoped to this NavBackStackEntry
    // Destroyed when navigating away from HomeScreen
}

// viewModel() — uses default or custom factory, no Hilt
@Composable
fun SimpleScreen(
    vm: SimpleViewModel = viewModel()   // only works for zero-arg or factory ViewModels
)
// SimpleViewModel cannot have injected dependencies without a custom factory
// Use for: test-only, no-dep ViewModels, or custom factory provision

// NavGraph-scoped ViewModel — shared between destinations
@Composable
fun CartScreen(navController: NavController) {
    val parentEntry = remember(navController) {
        navController.getBackStackEntry("checkout_graph")
    }
    val checkoutVm: CheckoutViewModel = hiltViewModel(parentEntry)
    // shared with PaymentScreen in same graph
}

// Assisted injection via hiltViewModel creationCallback
@Composable
fun ProductDetail(productId: String) {
    val vm: ProductDetailViewModel = hiltViewModel(
        creationCallback = { factory: ProductDetailViewModel.Factory ->
            factory.create(productId)
        }
    )
}

// Testing: provide fake ViewModel in tests
@Composable
fun HomeScreen(vm: HomeViewModel = hiltViewModel()) {
    // In tests: @TestInstallIn replaces the ViewModel's repo with fake
    // OR use viewModel { HomeViewModel(FakeRepo()) } for pure Compose tests
}

hiltViewModel() and viewModel() serve different purposes in Compose. hiltViewModel() retrieves a ViewModel using the Hilt-generated factory, which means all of the ViewModel's constructor dependencies are resolved from the Hilt component graph. It requires @HiltViewModel on the ViewModel class and @AndroidEntryPoint on the host Activity — without these, the composable cannot locate the Hilt component to retrieve the factory from. This is the correct API for any ViewModel that has injected dependencies.

viewModel() is the base Compose ViewModel API with no Hilt involvement. It uses the default ViewModelProvider.Factory, which can only create ViewModels with no-argument constructors. For zero-dependency ViewModels this works, but for any ViewModel with dependencies you must provide a custom factory — which is exactly what Hilt's factory is. Use viewModel(factory = myFactory) when you have a manually-built factory or when using assisted injection before Hilt 2.49's creationCallback API. In 2025, hiltViewModel(creationCallback = ...) subsumes this use case for Hilt-managed ViewModels.

Testing Compose screens with Hilt involves a choice of strategy. Full integration tests use @HiltAndroidTest with @TestInstallIn to replace production dependencies — the composable receives its real ViewModel with controlled fakes. For pure Compose UI tests that do not need a real ViewModel, the recommended pattern is to separate the screen into a stateless composable that accepts state and callbacks as parameters: @Composable fun ProfileContent(state: ProfileUiState, onRetry: () -> Unit). This composable can be tested with fake state directly, with no ViewModel, no Hilt, and no Android infrastructure — just Compose test rules and a preview-ready component.

💡 Interview Tip

"hiltViewModel() is the standard for production Compose screens — it hooks into Hilt's factory, respects Navigation back stack scoping, and supports assisted injection via creationCallback. viewModel() is for simple cases or tests where you want full control over the ViewModel instance."

Mark it done or save it in the question bank · Back to top

Hard

47. What is the "Hilt + Compose" injection anti-pattern of passing ViewModel down the composable tree? How do you fix it?

Passing a ViewModel as a parameter through multiple composable layers (ViewModel drilling) tightly couples composables to a specific ViewModel — breaking reusability and testability. State hoisting with callbacks is the correct pattern.

// ❌ ANTI-PATTERN: ViewModel drilling
@Composable
fun ProfileScreen(vm: ProfileViewModel = hiltViewModel()) {
    ProfileContent(vm = vm)              // passing VM down
}

@Composable
fun ProfileContent(vm: ProfileViewModel) {  // coupled to ViewModel
    ProfileHeader(vm = vm)                  // drilling deeper
    ProfileStats(vm = vm)
}
// ProfileContent can't be previewed, can't be tested standalone
// It's tied to ProfileViewModel forever

// ✅ CORRECT: state + callbacks, ViewModel at the top
@Composable
fun ProfileScreen(vm: ProfileViewModel = hiltViewModel()) {
    val state by vm.uiState.collectAsStateWithLifecycle()
    ProfileContent(
        state = state,             // pass data, not ViewModel
        onEditClick = vm::onEditClick,
        onFollowClick = vm::onFollowClick
    )
}

@Composable
fun ProfileContent(              // stateless — no ViewModel reference
    state: ProfileState,
    onEditClick: () -> Unit,
    onFollowClick: () -> Unit
) {
    ProfileHeader(name = state.name, onEditClick = onEditClick)
    ProfileStats(followers = state.followers, onFollowClick = onFollowClick)
}
// ProfileContent is now fully previewable and standalone-testable

// @Preview — works because no ViewModel needed
@Preview
@Composable
fun ProfileContentPreview() {
    ProfileContent(
        state = ProfileState(name = "Alice", followers = 1234),
        onEditClick = {}, onFollowClick = {}
    )
}

Passing a ViewModel through multiple composable layers — ProfileScreen receives the ViewModel, passes it to ProfileHeader, which passes it to AvatarSection — is called prop drilling and is an anti-pattern in Compose. Each intermediate composable gains a dependency on the ViewModel type even though it may only use one field from it. This makes every composable in the chain harder to preview (requires a real or fake ViewModel), harder to test in isolation, and impossible to reuse in contexts where the ViewModel does not exist.

State hoisting is the solution. Extract the data the child composables need and pass it as plain values: ProfileContent(uiState: ProfileUiState, onAvatarClick: () -> Unit, onEditProfile: () -> Unit). The child composables receive exactly what they display and exactly what they invoke — nothing more. The ViewModel interaction is contained at the screen level: ProfileScreen calls hiltViewModel(), observes its StateFlow, and passes the resulting state and lambda callbacks down to stateless children. The ViewModel is a detail of the screen root, invisible to all child composables.

The testability benefit is immediate and significant. A stateless composable like ProfileContent is tested with composeTestRule.setContent { ProfileContent(fakeState, onAvatarClick = {}) } — no Hilt, no ViewModel, no Android infrastructure beyond the compose test rule. It can be previewed in Android Studio with a preview function that constructs the fake state inline. The pattern also improves reusability: ProfileContent can be embedded in an onboarding flow, a settings screen, or a admin panel with different data sources, because it has no dependency on where the data comes from.

💡 Interview Tip

"The rule: ViewModel is injected once at the screen level. Every child composable below it receives plain data types and lambdas — not the ViewModel. If a child composable has ViewModel in its parameter list, it's a sign of drilling. Hoist state up, pass data down."

Mark it done or save it in the question bank · Back to top

Hard

48. Scenario: You have a background service that needs to post updates to the UI. How do you architect this cross-component communication cleanly using DI?

Services and Activities/ViewModels live in different components — they can't directly inject each other. The clean solution is a @Singleton SharedFlow event bus or a @Singleton StateHolder that both sides inject.

// Pattern: @Singleton StateHolder — both Service and ViewModel inject it
@Singleton
class DownloadStateHolder @Inject constructor() {
    private val _progress = MutableStateFlow<Map<String, Int>>(emptyMap())
    val progress = _progress.asStateFlow()

    fun updateProgress(fileId: String, percent: Int) {
        _progress.update { it + (fileId to percent) }
    }
    fun remove(fileId: String) {
        _progress.update { it - fileId }
    }
}

// Service — injects StateHolder, updates progress
@AndroidEntryPoint
class DownloadService : Service() {
    @Inject lateinit var stateHolder: DownloadStateHolder

    private fun startDownload(fileId: String, url: String) {
        ioScope.launch {
            api.downloadFile(url).collect { progress ->
                stateHolder.updateProgress(fileId, progress)
            }
            stateHolder.remove(fileId)
        }
    }
}

// ViewModel — injects same StateHolder, observes updates
@HiltViewModel
class FilesViewModel @Inject constructor(
    private val stateHolder: DownloadStateHolder,  // SAME @Singleton instance
    private val fileRepo: FileRepository
) : ViewModel() {
    val downloads = stateHolder.progress
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
}

Sharing state between a Service and a ViewModel is a classic Android challenge — the two run in the same process but with different lifecycles, and direct references between them create coupling that causes leaks. The DI-based solution is a @Singleton-scoped state holder: a class injected into both the Service (via @AndroidEntryPoint field injection) and the ViewModel (via constructor injection). Since both receive the same singleton instance, state written by the Service is immediately observable by the ViewModel without any direct reference between them.

StateFlow is the ideal carrier for this shared state. The Service holds a MutableStateFlow internally and exposes an immutable StateFlow property. The ViewModel collects the exposed flow in a viewModelScope-bounded coroutine and maps it to UI state. Thread safety is built in: StateFlow.update { it.copy(...) } is atomic, so concurrent writes from parallel download tasks in the Service update the state correctly without explicit locking. The ViewModel never interacts with the MutableStateFlow — it only reads, enforcing the one-directional data flow principle.

Testing each side independently is straightforward with this architecture. The ViewModel test creates a FakeDownloadStateHolder — a test double that implements the same interface — and manually advances its state flow: fakeHolder.emitProgress(50). The ViewModel reacts as it would with the real service, and assertions verify the correct UI state mapping. The Service test verifies that its business logic correctly updates the state holder without needing a real ViewModel. The singleton decoupling that makes the production code clean also makes the tests clean — neither test exercises the other's code, and neither requires Android infrastructure beyond its own class.

💡 Interview Tip

"Service and ViewModel can't inject each other — different component lifecycles. Inject a @Singleton StateHolder into both. Service writes progress to StateFlow, ViewModel observes it. The StateHolder is the mediator. Testing: manually call stateHolder.updateProgress() in ViewModel tests — no real Service needed."

Mark it done or save it in the question bank · Back to top

Hard

49. How do you inject environment-specific base URLs, API keys, and configuration into Hilt without hardcoding them?

Build configuration injection is a DI design problem — hardcoded strings in @Provides methods are a maintenance issue. The clean pattern separates config declaration from config consumption using a typed AppConfig object.

// AppConfig — typed container for environment-specific values
data class AppConfig(
    val apiBaseUrl: String,
    val apiVersion: String,
    val enableStrictMode: Boolean,
    val logLevel: LogLevel
)

// Build config constants defined in build.gradle.kts
// android { defaultConfig {
//   buildConfigField("String", "API_BASE_URL", '"https://api.myapp.com"')
// }}

// @Provides factory — reads from BuildConfig at module load time
@Module @InstallIn(SingletonComponent::class)
object AppConfigModule {
    @Provides @Singleton
    fun provideAppConfig(): AppConfig = AppConfig(
        apiBaseUrl = BuildConfig.API_BASE_URL,
        apiVersion = BuildConfig.API_VERSION,
        enableStrictMode = BuildConfig.DEBUG,
        logLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.ERROR
    )
}

// Network module consumes AppConfig — no BuildConfig references here
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideRetrofit(config: AppConfig): Retrofit = Retrofit.Builder()
        .baseUrl(config.apiBaseUrl)        // from injected config
        .build()
}

// Secure API keys — read from local.properties, not BuildConfig
// Never check API keys into source control!
// build.gradle.kts reads from local.properties:
// val props = Properties().apply { load(rootProject.file("local.properties").inputStream()) }
// buildConfigField("String", "ANALYTICS_KEY", '"${props["ANALYTICS_KEY"]}"')

// Test override — @TestInstallIn replaces config
@TestInstallIn(components = [SingletonComponent::class], replaces = [AppConfigModule::class])
@Module
object TestAppConfigModule {
    @Provides
    fun provideTestConfig(): AppConfig = AppConfig(
        apiBaseUrl = "http://localhost:8080",  // local mock server
        apiVersion = "v1", enableStrictMode = true, logLevel = LogLevel.VERBOSE
    )
}

Scattered BuildConfig.API_URL references throughout the codebase create a maintenance problem: every class that needs environment configuration couples directly to the build system. The typed AppConfig pattern centralises this. Define a data class data class AppConfig(val apiBaseUrl: String, val isDebug: Boolean, val analyticsEnabled: Boolean) and provide it as a singleton binding. The AppConfigModule is the only class that references BuildConfig — it translates build-system constants into the typed config object. Everything else injects AppConfig.

The NetworkModule that builds Retrofit takes AppConfig as a constructor parameter: @Provides fun provideRetrofit(config: AppConfig): Retrofit = Retrofit.Builder().baseUrl(config.apiBaseUrl).build(). Zero BuildConfig references appear in the network layer. This separation makes the NetworkModule fully testable: provide a test AppConfig with a localhost URL and the module builds a Retrofit instance pointing to your local mock server or an in-process HTTP server. The module's logic — adding converters, setting timeouts, configuring OkHttp — can be tested independently of the environment configuration.

API keys and secrets belong in local.properties, which is git-ignored. The Gradle build script reads the key from local.properties and writes it to BuildConfig. The AppConfigModule reads it from BuildConfig and injects it into AppConfig. This chain keeps secrets out of version control while making them available at runtime. For testing with @TestInstallIn, the test AppConfigModule provides a hardcoded test config — typically a localhost base URL and feature flags configured for the test scenario — and the rest of the production modules consume the test config without any changes.

💡 Interview Tip

"Centralise all BuildConfig.* reads into one AppConfigModule. Everything else injects AppConfig — it never touches BuildConfig directly. In tests, @TestInstallIn provides TestAppConfigModule with localhost URLs. No BuildConfig references in test code — clean separation of concerns."

Mark it done or save it in the question bank · Back to top

Hard

50. Scenario: Reflect on everything you know about DI. When is DI the wrong solution, and what architectural principle guides that decision?

DI mastery includes knowing its limits. Over-engineering with DI adds complexity without proportional benefit — the guiding principle is that DI should simplify the system, not complicate it.

// The principle: DI is a tool, not a religion
// Use it where it provides clear value; skip it where it doesn't

// ❌ DI is WRONG for: utility functions
// Don't do:
@Singleton
class DateFormatter @Inject constructor() {
    fun format(ts: Long): String = /* ... */ ""
}
// Do: object DateFormatter { fun format(ts: Long) = ... }

// ❌ DI is WRONG for: value objects and data classes
data class Money(val amount: Double, val currency: String)
// Money is data, not a service — never inject it via DI

// ❌ DI is WRONG for: simple scripts / one-off tools
// A 50-line Gradle task — DI setup overhead exceeds the benefit

// ❌ DI is WRONG for: things that don't vary
@Singleton
class MathUtils @Inject constructor() {
    fun add(a: Int, b: Int) = a + b  // ❌ will NEVER change — just use a function
}

// ✅ DI IS RIGHT for: services that VARY (testability)
class UserViewModel @Inject constructor(
    private val repo: UserRepository   // varies: real vs fake in tests
)

// ✅ DI IS RIGHT for: shared expensive resources
@Singleton
fun provideOkHttp(): OkHttpClient  // expensive, shared, one instance

// ✅ DI IS RIGHT for: lifecycle management
@ActivityRetainedScoped
class UserSession  // lifetime tied to Activity — automatically cleaned up

// The DI heuristic:
// Q1: "Can this dependency change?" (test vs prod) → DI it
// Q2: "Is this expensive to create and shareable?" → DI it @Singleton
// Q3: "Does this have a complex lifecycle?" → DI it with right scope
// If all three are NO → don't inject it

Dependency injection has genuine costs: it adds a layer of indirection, requires annotation processing or framework initialisation, and imposes a conceptual model that newcomers must learn. Applying DI to everything — including stateless utilities, value objects, and constants — inflates the DI graph with unnecessary complexity without providing corresponding benefit. The discipline of deciding when DI adds value versus when it adds friction is part of writing a maintainable architecture.

The three-question heuristic cuts through the noise. First: can this dependency vary — does it need a real implementation in production and a fake in tests, or different implementations in different environments? If yes, DI provides the swap mechanism and is clearly valuable. Second: is this dependency expensive to create and shared across multiple consumers? If yes, DI's scoping manages the singleton lifecycle correctly. Third: does this dependency have a lifecycle that needs management — should it be created when a screen opens and cleared when it closes? If yes, DI's scoping is the right tool for lifecycle management. If all three answers are no, DI likely adds indirection without value.

The architectural principle behind these questions is inversion of control: DI makes sense when the class that uses a dependency should not be the one deciding which implementation to use, how expensive the creation cost is, or when the instance should be cleared. Inverting control is valuable when control is genuinely external — when the test environment, the build variant, the user's subscription tier, or the application lifecycle should make these decisions. For a pure math function or an immutable data record, control is not worth inverting: the behaviour is always the same, creation is trivially cheap, and there is no lifecycle to manage. Knowing the difference is what separates over-engineered code from well-designed code.

💡 Interview Tip

The senior answer to "when NOT to use DI" demonstrates the maturity to question your own tools. "DI is a tool for managing variation and lifetime. A stateless math utility will never vary and has no lifetime — DI adds complexity for zero gain. The smell: if you'd never write a fake for it in tests, you probably don't need to inject it."

Mark it done or save it in the question bank · Back to top

More Android interview topics

See all 600+ questions in the question bank