Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 027 / 100

Dependency injection with Hilt: best practices explained

Wire your app without manual factories.

Expona Admin6 min read

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.

kotlin
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:

kotlin
<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.

kotlin
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:

kotlin
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:

kotlin
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.

kotlin
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:

kotlin
@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:

kotlin
@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:

kotlin
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 @HiltAndroidApp to bootstrap the dependency container at startup.
  • Use @HiltViewModel for ViewModels and @AndroidEntryPoint for Activities and Fragments to enable injection at those entry points.
  • Create @Module classes with @Provides methods to configure and inject third-party libraries like Retrofit and Room.
  • Use @Singleton for app-wide, expensive objects; use @ViewModelScoped for 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles