Introduction
Manual dependency injection in Android apps quickly becomes a maintenance nightmare as your project grows. You end up writing factory classes, passing constructors down through multiple layers, and managing the lifecycle of shared objects like database instances and HTTP clients. Dependency injection with Hilt: best practices explained shows us how to automate this wiring and keep our codebase clean and testable.
Hilt, built on top of Dagger, is Google's recommended dependency injection framework for Android. It reduces boilerplate significantly while maintaining the type safety and compile-time verification we need in production apps. Over this article, we'll walk through setting up Hilt, injecting dependencies into ViewModels, creating reusable modules for third-party libraries, and managing object scopes correctly.
Setup: @HiltAndroidApp on the Application Class
Every Hilt-enabled Android project starts with annotating your custom Application class. This single annotation tells Hilt to generate the dependency container that will manage all your app's dependencies.
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// Hilt container is now available
}
}Make sure this class is declared in your AndroidManifest.xml:
<application
android:name=".MyApplication"
...>
</application>That's it for bootstrapping. Hilt will generate an instance graph at compile time, and you can now inject dependencies anywhere in your app. If you don't have a custom Application class yet, this is the moment to create one. Without it, Hilt has nowhere to anchor its dependency container.
Inject: @Inject Constructors and @HiltViewModel
Once your Application class is set up, you can start injecting dependencies. The most straightforward approach is constructor injection. Hilt examines the constructor parameters and automatically provides them from the generated container.
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class ProductsViewModel @Inject constructor(
private val repo: ProductRepository
) : ViewModel()The @HiltViewModel annotation tells Hilt this is a ViewModel that needs dependency injection. Hilt handles the creation and lifecycle, so your Activity or Fragment doesn't need factory code:
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class ProductsActivity : AppCompatActivity() {
private val viewModel: ProductsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// viewModel is ready to use
}
}The @AndroidEntryPoint annotation marks a component (Activity, Fragment, Service, etc.) as an injection site. Hilt generates a subclass that performs the injection automatically. You don't write the subclass yourself—the annotation processor handles it at compile time.
For dependencies that aren't ViewModels, you can still use constructor injection in regular classes:
class ProductRepository @Inject constructor(
private val api: ProductApi,
private val database: ProductDatabase
) {
// repository code
}Hilt will recursively resolve the ProductApi and ProductDatabase dependencies, as long as they are also injectable (either through their own constructors or through modules, which we'll cover next).
Modules: @Provides for Retrofit, Room and Other Library Objects
Not everything can be injected via a simple constructor. Third-party libraries like Retrofit and Room require configuration before they can be instantiated. This is where Hilt modules come in. A module is a class that provides pre-configured instances of external dependencies.
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideProductApi(retrofit: Retrofit): ProductApi {
return retrofit.create(ProductApi::class.java)
}
}Each @Provides method tells Hilt how to construct a specific dependency. Notice how provideProductApi takes a Retrofit parameter—Hilt automatically passes the instance provided by provideRetrofit. This dependency graph is resolved at compile time, so you'll catch wiring errors before the app runs.
Room databases follow a similar pattern:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(context: Context): ProductDatabase {
return Room.databaseBuilder(
context,
ProductDatabase::class.java,
"product_db"
).build()
}
@Provides
fun provideProductDao(database: ProductDatabase): ProductDao {
return database.productDao()
}
}Hilt automatically provides the application Context, so you can request it in any @Provides method. DAOs typically don't need @Singleton because they're lightweight and derived from the database instance, which is already scoped.
Modules are container configuration code. Keep them focused—group related providers together (all network code in one module, all database code in another). As your app grows, this organization pays dividends when you need to mock dependencies for testing.
Scopes: Singleton for App-Wide Objects
Scope annotations control the lifetime of dependency instances. Without a scope, Hilt creates a new instance every time you request a dependency. For expensive objects like Retrofit clients, database connections, and shared preferences, this is wasteful and wrong.
The @Singleton annotation ensures Hilt creates the object once and reuses it for the entire app lifetime:
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideSharedPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
}
}SingletonComponent is the right scope for app-level dependencies. Its lifetime is tied to the Application itself.
For ViewModel-scoped dependencies, use @ViewModelScoped:
import dagger.hilt.android.scopes.ViewModelScoped
@ViewModelScoped
class ProductCart @Inject constructor(
private val repo: ProductRepository
) {
// This instance lives as long as the ViewModel
}A ViewModel-scoped dependency is created fresh for each ViewModel instance and discarded when the ViewModel is destroyed. This is useful for state that's specific to a screen but shared across that screen's lifecycle.
Common scopes in Hilt:
- @Singleton: App-wide lifetime (Retrofit, Room, SharedPreferences)
- @ViewModelScoped: ViewModel lifetime (carts, form state, temporary data)
- @ActivityScoped: Activity lifetime (rarely used; prefer ViewModels)
- No scope (unscoped): New instance each time (use sparingly)
Choosing the right scope is crucial for correctness and performance. A Singleton Retrofit client means all requests share connection pooling. A ViewModel-scoped form validator means each screen gets its own instance, but it persists across configuration changes (unlike Fragment fields).
Common Mistakes
Forgetting @HiltAndroidApp: Without it, Hilt never generates the container, and your app crashes with a cryptic error. Always start by annotating your Application class.
Circular dependencies: If Class A needs Class B and Class B needs Class A, the dependency graph can't be resolved. Hilt will catch this at compile time, but the error message can be confusing. Restructure to break the cycle—often by introducing an intermediate abstraction or factory.
Misusing scopes: Applying @Singleton to a repository that holds mutable state means that state is shared across the entire app. If your repository caches data that should be screen-specific, use @ViewModelScoped instead. Conversely, creating a new Retrofit client for each request (unscoped) wastes resources.
Not using @Provides for library objects: Trying to inject a Retrofit instance without a module that provides it will cause a compile error. Remember: Hilt can only inject things it knows how to build. Libraries require modules.
Mixing manual factories with Hilt: If you keep hand-written factory classes alongside Hilt, you're missing the point. Migrate fully to Hilt to keep your dependency wiring centralized and maintainable.
Key Takeaways
- Annotate your Application class with
@HiltAndroidAppto bootstrap the dependency container at startup. - Use
@HiltViewModelfor ViewModels and@AndroidEntryPointfor Activities and Fragments to enable injection at those entry points. - Create
@Moduleclasses with@Providesmethods to configure and inject third-party libraries like Retrofit and Room. - Use
@Singletonfor app-wide, expensive objects; use@ViewModelScopedfor screen-specific state. - Let Hilt's compile-time verification catch wiring errors early—it's far better than runtime crashes in production.
- Keep your modules organized by domain (network, database, preferences) for long-term maintainability.






Comments
Loading comments…