DEV Community

Cover image for Hilt & Dependency Injection: Scoping, Testing, and Multi-Module Setup
beefed.ai
beefed.ai

Posted on Originally published at beefed.ai

Hilt & Dependency Injection: Scoping, Testing, and Multi-Module Setup

  • Why dependency injection still wins for non-trivial Android apps
  • How to wire Hilt quickly: the minimal setup and annotations that matter
  • Understanding Hilt scoping: components, lifecycles, and surprising gotchas
  • Testing with Hilt: unit tests, instrumentation, and avoiding slow builds
  • Actionable checklist: implement Hilt in 10 steps (scoping, testing, multi-module)

Ad-hoc object construction and ad-hoc singletons are among the top reasons Android codebases rot: tangled lifecycles, hidden memory retention, and tests that either spin up servers or flake. Hilt gives you a compile-time DI surface built on Dagger and a set of generated components that map directly to Android lifecycles, so your wiring is explicit, testable, and lifecycle-aware.

You’re seeing a specific pattern: feature teams add ad-hoc service locators, QA reports flaky UI tests that rely on real servers, developers repeatedly leak Activity contexts via poorly scoped singletons, and build-time codegen fails when a new Gradle module is introduced. Those symptoms point to missing lifecycle-aware DI, ambiguous ownership of objects, and insufficient test seams — exactly the problems Hilt and a disciplined DI strategy are designed to fix.

Why dependency injection still wins for non-trivial Android apps

Dependency injection isn’t a framework fetish — it’s a practical technique that keeps object creation orthogonal to business logic. Hilt gives you three concrete advantages you can measure:

  • Compile-time graph validation. Hilt (via Dagger) verifies the graph at build time so missing bindings and cycles surface before QA.
  • Lifecycle-aligned components. Hilt generates components whose lifetimes match Android classes (Application, Activity, Fragment, ViewModel), which reduces lifecycle-related leaks and NPEs from late initialization.
  • Test seams without plumbing. With Hilt’s testing helpers you can replace production bindings in test source sets or per-test, which reduces flakiness and speeds test feedback.

When to adopt Hilt:

  • It’s valuable once you have multiple screens, any remotely complex data layer, or a multi-module layout where wiring errors cost time. Small one-off prototypes rarely need it; large teams and long-lived products benefit immediately. Use Hilt when you need compile-time safety, Jetpack integration, and consistent test hooks.

Short, idiomatic example that shows the single‑source‑of‑truth idea — constructor injection as the default:

class LoginRepository @Inject constructor(
  private val api: AuthApi,
  private val prefs: UserPrefs
)

@HiltViewModel
class LoginViewModel @Inject constructor(
  private val repo: LoginRepository
) : ViewModel()
Enter fullscreen mode Exit fullscreen mode

This forces dependencies into constructors and makes the class trivially testable.

How to wire Hilt quickly: the minimal setup and annotations that matter

Get to working Hilt code with four small moves.

1) Add the plugin + dependencies (use a central hilt_version and the latest stable version from the docs).

Example (module-level, Kotlin DSL notation):

plugins {
  id("com.android.application")
  kotlin("android")
  kotlin("kapt")
  id("com.google.dagger.hilt.android")
}

dependencies {
  implementation("com.google.dagger:hilt-android:<hilt_version>")
  kapt("com.google.dagger:hilt-android-compiler:<hilt_version>")
}
Enter fullscreen mode Exit fullscreen mode

The official docs cover exact Gradle/plugin wiring and additional artifacts (navigation, work, compose).

2) Bootstrap your app: annotate the Application with @HiltAndroidApp:

@HiltAndroidApp
class App : Application()
Enter fullscreen mode Exit fullscreen mode

This triggers Hilt’s code generation and creates the application-level component.

3) Annotate Android classes that need injection with @AndroidEntryPoint and use constructor injection where possible:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
  @Inject lateinit var analytics: AnalyticsService
}
Enter fullscreen mode Exit fullscreen mode

For ViewModels use @HiltViewModel and constructor injection; Compose callers generally use hiltViewModel() to obtain instances.

4) Provide non-constructor-bindable types with modules and @InstallIn:

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthOkHttp

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
  @Provides @AuthOkHttp @Singleton
  fun authOkHttp(): OkHttpClient = OkHttpClient.Builder()
    .addInterceptor(AuthInterceptor())
    .build()
}
Enter fullscreen mode Exit fullscreen mode

Use @Binds (abstract, interface → impl) for interface bindings and @Provides for third-party types. The @InstallIn target determines visibility.

Important: the scope annotation on a binding must match the component you @InstallIn. Mis-scoped bindings produce compile errors.

Understanding Hilt scoping: components, lifecycles, and surprising gotchas

Hilt’s generated components map to Android lifecycles. That mapping is the foundation for correct scoping.

Component Scope annotation Typical lifetime (created / destroyed)
SingletonComponent @Singleton Application onCreate → process end.
ActivityRetainedComponent @ActivityRetainedScoped First Activity onCreate → last Activity onDestroy (survives rotations).
ActivityComponent @ActivityScoped Activity onCreate → Activity onDestroy (destroyed on rotation).
FragmentComponent @FragmentScoped Fragment onAttach → Fragment onDestroy.
ViewModelComponent @ViewModelScoped ViewModel created → cleared.
ViewComponent / ViewWithFragmentComponent @ViewScoped View lifecycle.
ServiceComponent @ServiceScoped Service onCreate → onDestroy.

Concrete implications and gotchas (practical, hard-won):

  • Scoping mismatch: binding a type with @Singleton inside a module @InstallIn(ActivityComponent::class) will fail — scope and install target must be compatible. Compilation errors, not runtime surprises, will catch this but the message can be noisy.
  • Choose narrow scopes. Prefer unscoped bindings for cheap, immutable objects (e.g., stateless mappers) and reserve scopes for objects holding resources or state that must be shared across a lifecycle. Over-scoping increases lifetime surface area and risk of leaks. Prefer constructor injection + stateless helpers.
  • Use @ActivityRetainedScoped for data that must survive configuration changes but should be tied to the Activity existence; use @ActivityScoped for UI-bound instances that must be recreated on rotation. Confusing these is a common source of "why my presenter doesn't survive rotation" bugs.
  • Context qualifiers matter: use @ApplicationContext for singletons, never inject an Activity into a @Singleton — that will leak. Hilt provides @ApplicationContext and @ActivityContext for exactly this reason.

Small example showing ActivityRetained:

@Module
@InstallIn(ActivityRetainedComponent::class)
object RetainedModule {
  @Provides @ActivityRetainedScoped
  fun provideSessionManager(): SessionManager = SessionManager()
}
Enter fullscreen mode Exit fullscreen mode

Testing with Hilt: unit tests, instrumentation, and avoiding slow builds

Testing is where DI pays back quickly, but Hilt’s testing surface has specific mechanics you must follow to avoid surprises.

Core testing primitives:

  • Annotate instrumented/UI tests with @HiltAndroidTest. Add HiltAndroidRule and call hiltRule.inject() in @Before. Use HiltTestApplication (or @CustomTestApplication) as the app used when running tests.
  • Use @TestInstallIn modules for replacing bindings across a whole test source set (fast and build-friendly). Use @UninstallModules + nested @InstallIn modules or @BindValue for single-test overrides, but @UninstallModules causes Hilt to generate a custom component for that test which can slow builds. Prefer @TestInstallIn when feasible.

Example: replace a production module across tests:

@Module
@TestInstallIn(
  components = [SingletonComponent::class],
  replaces = [AnalyticsModule::class]
)
object FakeAnalyticsModule {
  @Provides @Singleton fun provideAnalytics(): Analytics = FakeAnalytics()
}
Enter fullscreen mode Exit fullscreen mode

Example: per-test override with @BindValue:

@HiltAndroidTest
class SettingsActivityTest {
  @get:Rule val hiltRule = HiltAndroidRule(this)

  @BindValue @JvmField val analytics: Analytics = FakeAnalytics()

  @Before fun setUp() { hiltRule.inject() }
  // test body...
}
Enter fullscreen mode Exit fullscreen mode

Testing caveats you will encounter in real projects:

  • Robolectric and the Hilt Gradle plugin do bytecode transforms that can interfere with tools like JaCoCo; the community has several patterns and the docs show recommended dependency entries for Robolectric tests. Run tests via Gradle in CI to keep transforms consistent.
  • launchFragmentInContainer from fragment-testing does not work with Hilt; the docs show a launchFragmentInHiltContainer helper used in the architecture-samples.
  • @UninstallModules is convenient but can noticeably increase build time because it generates a fresh test component per test class; prefer source-set-wide @TestInstallIn modules for whole-suite replacements.

When to avoid Hilt in unit tests:

  • For plain JVM unit tests that don’t require Android runtime (fast, isolated ViewModel tests), construct the system under test with fakes or simple manual injection instead of bootstrapping Hilt — this keeps tests fast and independent of annotation processing.

Actionable checklist: implement Hilt in 10 steps (scoping, testing, multi-module)

Use this checklist as a practical playbook you can run this afternoon. Each step is short and prescriptive.

  1. Project hygiene — centralize versions: add a hilt_version in gradle.properties or a versions catalog and add the Gradle plugin at the root level.
  2. Add module dependencies: in the app module add implementation("com.google.dagger:hilt-android:$hilt_version") and kapt("com.google.dagger:hilt-android-compiler:$hilt_version") and id("com.google.dagger.hilt.android") plugin.
  3. Bootstrap the app: create @HiltAndroidApp class App : Application() and switch the Application entry in AndroidManifest if needed.
  4. Prefer constructor injection: convert new/ServiceLocator.get() calls into @Inject constructors. Replace field injection only in Android entry points (Activity / Fragment) where constructor injection isn’t possible.
  5. Provide third-party types with modules: use @Module, @InstallIn(SingletonComponent::class), prefer @Binds for interface→implementation, @Provides for factory logic. Keep modules small and cohesive.
  6. Apply qualifiers for same-type multiples: define @Qualifier annotations for alternate OkHttpClient or Retrofit instances. Use @Retention(AnnotationRetention.BINARY).
  7. Align scopes with lifecycles: for long-lived singletons use @Singleton; for objects that should survive rotation but be tied to the Activity lifecycle use @ActivityRetainedScoped; UI-bound instances use @ActivityScoped or @FragmentScoped. Check component lifetimes when in doubt.
  8. Testing setup: add com.google.dagger:hilt-android-testing to androidTest and test where needed; annotate tests with @HiltAndroidTest, use HiltAndroidRule, and favor @TestInstallIn for suite-wide replacements. Use @BindValue for quick per-test fakes.
  9. Multi-module wiring: ensure the app module that compiles @HiltAndroidApp has transitive visibility of all Hilt-annotated classes and modules used in other Gradle modules. For dynamic/feature modules, follow the @EntryPoint + Dagger component dependencies pattern: declare an @EntryPoint in the app (installed in SingletonComponent), create a Dagger component in the feature module that depends on that entry point, and build/inject explicitly at runtime.
  10. Watch for the usual pitfalls: do not hold Activity/Fragment references in @Singleton objects; do not mix incompatible scopes; avoid frequent use of @UninstallModules in many tests because it affects build times. Use the Jetpack/Hilt integration pages for Compose/Nav specifics (e.g., hiltViewModel()).

Quick checklist to run before a release: run the app under LeakCanary, run your instrumented Hilt tests with HiltTestApplication, run the unit test suite without Hilt where possible (fast feedback), and verify that no @Singleton binds an Activity or View.

Sources:
Dependency injection with Hilt - Official Hilt setup, annotations (@HiltAndroidApp, @AndroidEntryPoint, @Module, @InstallIn), context qualifiers and basic usage patterns.

Hilt testing guide - How to use @HiltAndroidTest, HiltAndroidRule, HiltTestApplication, @TestInstallIn, @UninstallModules, and @BindValue; Robolectric and instrumented test notes.

Hilt in multi-module apps - Requirements for transitive dependencies, @EntryPoint usage, and Dagger component pattern for feature modules.

Hilt components and scopes (Dagger docs) - The generated component hierarchy, scope annotations, and component default bindings.

Improve app performance with Kotlin coroutines - viewModelScope, lifecycleScope, Dispatchers.IO recommendations and structured concurrency guidelines.

Use Hilt with other Jetpack libraries - ViewModel, Navigation, Compose integrations and hiltViewModel() guidance.

Hilt testing (Dagger site) - Hilt testing philosophy and additional testing APIs.

Final note: Hilt is what lets you turn lifecycle chaos into a predictable wiring diagram — treat components as bounded containers, prefer constructor injection, and reserve scopes for genuinely shared state; with those rules your codebase will become easier to reason about, faster to test, and far less brittle.

Top comments (0)