Home / Blog / Dagger Hilt

Dagger Hilt without the boilerplate headache.

Dependency injection on Android earned a reputation for ceremony — classic Dagger asked for a lot of wiring before it did anything useful. Hilt removes most of that. But DI is still only worth the effort if it buys you something concrete. For me the payoff is one word: testability. Everything below is in service of that.

Why inject at all

The app has exactly the kind of dependencies you don't want to new up by hand all over the codebase: a BLE manager, a Room database, repositories that sit on top of them. Two reasons to inject them:

  • Lifetime. The database and the BLE manager should be single instances that live as long as the app — created once, not per-screen.
  • Substitution. In a test I want to swap the real BLE manager for a fake that replays canned payloads, without touching the code under test. That single ability is what makes the tests I care about possible.

The whole setup, briefly

Annotate the app, declare a module for things Hilt can't construct on its own, and inject where needed. That's most of it:

@HiltAndroidApp
class SoilCubApp : Application()

@Module
@InstallIn(SingletonComponent::class)
object DataModule {

    @Provides @Singleton
    fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase =
        Room.databaseBuilder(ctx, AppDatabase::class.java, "soilcub.db")
            .addMigrations(MIGRATION_1_2)
            .build()

    @Provides
    fun provideReadingDao(db: AppDatabase): ReadingDao = db.readingDao()
}

For interfaces I use @Binds instead of @Provides — less code, and it makes the "this implementation satisfies that contract" relationship explicit:

@Module
@InstallIn(SingletonComponent::class)
abstract class BleModule {
    @Binds @Singleton
    abstract fun bindBleManager(impl: NordicBleManager): BleManager
}

Because BleManager is an interface, the ViewModel just constructor-injects it and never knows which implementation it got:

@HiltViewModel
class ReadingViewModel @Inject constructor(
    private val ble: BleManager,
    private val repo: ReadingRepository
) : ViewModel()

The testability payoff

Here's where it pays off. In a test I replace the real BLE module with a fake that emits a scripted stream of readings — and the ViewModel, the repository, and the database all run for real against it:

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [BleModule::class]
)
abstract class FakeBleModule {
    @Binds abstract fun bindFakeBle(impl: FakeBleManager): BleManager
}
Good DI isn't about the annotations. It's that you can replace any real dependency with a fake at the boundary — and test everything else for real.

Keep modules small

The one habit that keeps Hilt from sprawling: modules organised by feature or layer (data, network, BLE), each small, rather than one giant AppModule that every change has to touch. Small modules read like a table of contents for the app's dependencies.

The takeaway

Hilt is Dagger with the boilerplate mostly gone — but I don't reach for it to be fashionable. I reach for it because it lets me hold the database and BLE manager as proper singletons and, crucially, swap them for fakes in tests. Dependency injection in service of testability, not DI for its own sake.