๐Ÿ’‰ Core Concept ยท ~30 min read ยท Intermediate โ†’ Advanced

Dependency Injection & Hilt

Why manually constructing objects destroys testability โ€” and how Hilt's compile-time dependency graph wires your entire app together without a single line of boilerplate you have to write yourself.

The problem with constructing your own dependencies

Every Android app has objects that depend on other objects. A ProductViewModel needs a ProductRepository. The repository needs a ProductDao and a ProductApiService. The API service needs a configured Retrofit instance. The Retrofit instance needs an OkHttpClient. The OkHttpClient needs an interceptor, a cache, and a certificate pinner.

If you build this yourself โ€” inside the class that needs it โ€” you end up with code like this in your ViewModel:

The "just construct it yourself" anti-pattern
class ProductViewModel : ViewModel() { private val repo = ProductRepositoryImpl( // โŒ constructed here api = Retrofit.Builder() .baseUrl("https://api.example.com") .client(OkHttpClient.Builder() .addInterceptor(AuthInterceptor()) .build()) .addConverterFactory(GsonConverterFactory.create()) .build() .create(ProductApiService::class.java), dao = AppDatabase.getInstance(context).productDao() // needs Context! ) }

This is a disaster. The ViewModel is now tightly coupled to every implementation detail of every dependency it has. You can't test the ViewModel without a real network. You can't swap ProductRepositoryImpl for a fake in tests. If you need to change how OkHttpClient is configured, you have to find every place it's constructed. And the ViewModel needs a Context, which is a memory leak waiting to happen.

The root problem is that the ViewModel is responsible for obtaining its dependencies rather than simply receiving them. Dependency Injection inverts this: you declare what you need, and something external provides it.

๐Ÿ”Œ Analogy

Think of electrical sockets. Your phone charger doesn't generate its own electricity โ€” it just plugs into the wall and receives power. The charger declares its need (a 230V socket), and the infrastructure provides it. Dependency Injection works the same way: your class declares its needs (its dependencies), and the DI container provides them. The class doesn't need to know where the power comes from.

What is Dependency Injection?

Dependency Injection is a technique where an object receives its dependencies from outside rather than creating them internally. That's the entire idea. The dependencies are "injected" โ€” passed in through a constructor, a property, or a method โ€” rather than instantiated inside the class.

๐Ÿ“‹ Without DI vs With DI โ€” who creates the dependencies?
โŒ Without DI ViewModel creates everything itself new ProductRepositoryImpl new new Retrofit ProductDao Tightly coupled. Impossible to test. Changing Retrofit = touching every class. โœ… With DI (Hilt) Hilt Container builds & owns all dependencies inject inject inject ViewModel Repo Interface Retrofit singleton Each class just declares what it needs. Hilt figures out how to provide it. Swap any implementation in tests with zero changes to the class itself.

Manual DI โ€” before reaching for a framework

Before Hilt, before even Dagger, you can do DI manually โ€” and understanding manual DI is the best way to understand what Hilt is actually doing for you. The pattern is simple: instead of a class constructing its own dependencies, you pass them in through the constructor.

Manual DI โ€” constructor injection, the right way
// โœ… Each class declares its needs. Nobody constructs their own deps. class ProductRepositoryImpl( private val api: ProductApiService, // injected private val dao: ProductDao // injected ) : ProductRepository class GetProductUseCase( private val repo: ProductRepository // injected (interface, not impl!) ) class ProductViewModel( private val getProduct: GetProductUseCase // injected ) : ViewModel() // The "composition root" โ€” one place that wires everything together // In Hilt this is generated automatically. Manually it looks like this: object AppContainer { private val okHttp = OkHttpClient.Builder().build() private val retrofit = Retrofit.Builder() .baseUrl("https://api.example.com") .client(okHttp) .addConverterFactory(GsonConverterFactory.create()) .build() val api: ProductApiService = retrofit.create(ProductApiService::class.java) lateinit var db: AppDatabase fun init(app: Application) { db = AppDatabase.build(app) } fun productViewModel() = ProductViewModel( getProduct = GetProductUseCase( repo = ProductRepositoryImpl(api = api, dao = db.productDao()) ) ) } // In tests: replace any piece trivially val vm = ProductViewModel( getProduct = GetProductUseCase(repo = FakeProductRepository()) )

Manual DI works โ€” Google's own Architecture Blueprints used it as a recommended approach for small apps. But it has real costs as apps scale: you write a lot of boilerplate, you have to manually handle scoping (ensuring you get the same Retrofit instance everywhere that needs one), and adding a new dependency to a class means updating every construction site. This is the problem Hilt (and Dagger before it) solves.

Hilt โ€” DI for Android

Hilt is Google's DI library for Android, built on top of Dagger 2. Dagger is a compile-time DI framework โ€” rather than using reflection at runtime (like Spring), it generates plain Java code at build time that wires your dependencies. Hilt is Dagger with a pre-configured setup designed specifically for Android's component hierarchy.

Here's what makes Hilt different from doing Dagger manually: Hilt knows about Android components โ€” Application, Activity, Fragment, ViewModel, Service, BroadcastReceiver. It provides pre-built Dagger components for each one, handles their lifecycles automatically, and generates the boilerplate Dagger code that previously required you to write dozens of lines per component. You annotate. Hilt generates. You ship.

Setup โ€” three annotations to rule them all

Hilt setup โ€” the three entry point annotations
// 1. @HiltAndroidApp โ€” on your Application class. Required. Triggers Hilt's code generation. @HiltAndroidApp class MyApp : Application() // 2. @AndroidEntryPoint โ€” on Activity, Fragment, View, Service, BroadcastReceiver // Tells Hilt: "inject fields annotated with @Inject into this class" @AndroidEntryPoint class ProductActivity : AppCompatActivity() { // Field injection (only when constructor injection isn't possible) @Inject lateinit var analytics: AnalyticsLogger private val vm: ProductViewModel by viewModels() } @AndroidEntryPoint class ProductFragment : Fragment() // parent Activity must also be @AndroidEntryPoint // 3. @HiltViewModel โ€” on ViewModel. Hilt provides @Inject constructor args automatically. @HiltViewModel class ProductViewModel @Inject constructor( private val getProduct: GetProductUseCase, savedState: SavedStateHandle // Hilt provides this automatically ) : ViewModel()

Modules โ€” teaching Hilt how to build things

For types you control (your own classes with @Inject constructors), Hilt figures out how to build them automatically. But for types you don't control โ€” third-party libraries, interfaces, classes that need configuration โ€” you write a Module that tells Hilt exactly how to create them.

A module is a class annotated with @Module and @InstallIn. The @InstallIn annotation says which Hilt component this module belongs to โ€” which determines its scope and lifetime. Inside the module, @Provides functions tell Hilt how to construct a type. @Binds functions tell Hilt which implementation to use for an interface.

Modules โ€” @Provides for instances, @Binds for interfaces
// Network module โ€” provides third-party objects Hilt can't construct itself @Module @InstallIn(SingletonComponent::class) // lives for the app's entire lifetime object NetworkModule { @Provides @Singleton // one instance, shared everywhere fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder() .addInterceptor(AuthInterceptor()) .certificatePinner(/* ... */) .build() @Provides @Singleton fun provideRetrofit(okHttp: OkHttpClient): Retrofit = // Hilt injects okHttp here Retrofit.Builder() .baseUrl(BuildConfig.API_URL) .client(okHttp) .addConverterFactory(GsonConverterFactory.create()) .build() @Provides @Singleton fun provideProductApi(retrofit: Retrofit): ProductApiService = retrofit.create(ProductApiService::class.java) } // Database module @Module @InstallIn(SingletonComponent::class) object DatabaseModule { @Provides @Singleton fun provideDatabase(@ApplicationContext app: Context): AppDatabase = Room.databaseBuilder(app, AppDatabase::class.java, "app.db").build() @Provides fun provideProductDao(db: AppDatabase): ProductDao = db.productDao() } // Repository module โ€” binds interface to implementation (the key architecture move) @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Binds @Singleton abstract fun bindProductRepository( impl: ProductRepositoryImpl // impl class has @Inject constructor ): ProductRepository // interface returned โ€” callers get the interface }
๐Ÿ“Œ @Provides vs @Binds

@Provides โ€” you write the function body that creates the object. Used for third-party types or objects that need configuration.
@Binds โ€” tells Hilt "when someone asks for interface X, give them implementation Y." More efficient than @Provides because Hilt generates less code. The function must be abstract. The implementation must have an @Inject constructor.

The Hilt component hierarchy

Hilt comes with a fixed set of components, each tied to an Android lifecycle. Every component is a child of the one above it โ€” a child component can access bindings from its parent, but not vice versa. This hierarchy determines two critical things: the scope of a binding (how long the instance lives) and the visibility of a binding (which classes can receive it).

๐Ÿ“‹ Hilt component hierarchy โ€” lifetime and scope
SingletonComponent @Singleton โ€ข Application lifetime Retrofit, OkHttp, Room DB, Repo impls ActivityRetainedComponent @ActivityRetainedScoped โ€ข survives rotation ViewModelComponent @ViewModelScoped ActivityComponent @ActivityScoped FragmentComponent @FragmentScoped ServiceComponent @ServiceScoped ViewComponent @ViewScoped Child inherits parent bindings. Parent cannot access child bindings. Unscoped = new instance every time it's requested.

Scopes โ€” how long does a dependency live?

By default, every time Hilt provides a dependency, it creates a new instance. If five classes ask for a ProductRepository, they each get their own instance. Most of the time that's wasteful โ€” you want one Retrofit instance, one database, one shared repository. That's what scope annotations are for: they tell Hilt to create one instance and reuse it for the lifetime of a specific component.

Annotation Component Lifetime Use for
@Singleton SingletonComponent App lifetime Retrofit, OkHttp, Room DB, Repositories
@ActivityRetainedScoped ActivityRetainedComponent Survives rotation State shared between ViewModel and Activity
@ViewModelScoped ViewModelComponent ViewModel lifetime Use cases, helpers scoped to one screen's ViewModel
@ActivityScoped ActivityComponent Activity instance Destroyed on rotation โ€” rarely needed
@FragmentScoped FragmentComponent Fragment lifetime Things only needed while a fragment is alive
(none) Any New each time Lightweight stateless helpers, factories
โš ๏ธ Scope Mismatch โ€” a common bug

A @Singleton scoped object cannot depend on a @ActivityScoped object. The singleton lives longer than the Activity โ€” Hilt will fail at compile time if you try. The rule: a dependency can only be injected into the same scope or a shorter-lived scope. Think of it as: you can't keep a reference to something that might be destroyed before you are.

@Inject constructor โ€” the cleanest way

For classes you own, you almost never need a module. Just annotate the constructor with @Inject and Hilt figures out how to build it โ€” it looks at the constructor parameters, finds providers for each one, and wires them up. No boilerplate, no module needed.

@Inject constructor โ€” Hilt does the rest
// Hilt sees @Inject constructor, reads the params, and knows how to build this. // ProductApiService and ProductDao must also be providable (via @Inject or a Module). class ProductRepositoryImpl @Inject constructor( private val api: ProductApiService, private val dao: ProductDao ) : ProductRepository class GetProductUseCase @Inject constructor( private val repo: ProductRepository // Hilt looks up the @Binds for this interface ) @HiltViewModel class ProductViewModel @Inject constructor( private val getProduct: GetProductUseCase, private val savedState: SavedStateHandle // Hilt provides this for free in ViewModels ) : ViewModel() // That's it. No factory. No ViewModelProvider.Factory. No wiring code anywhere. // In your Fragment: private val vm: ProductViewModel by hiltViewModel() // Hilt + Compose // or private val vm: ProductViewModel by viewModels() // Hilt + Fragment

Qualifiers โ€” when you need two of the same type

Sometimes you need Hilt to provide two different instances of the same type โ€” say, two different OkHttpClients: one with auth interceptors for your main API, and one without for a public CDN. Since the type is the same, Hilt can't distinguish them automatically. Qualifiers solve this: they're annotations that tag a binding with extra identity so Hilt can find the right one.

Qualifiers โ€” distinguishing two bindings of the same type
// 1. Define your qualifier annotations @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AuthenticatedClient @Qualifier @Retention(AnnotationRetention.BINARY) annotation class PublicClient // 2. Provide both โ€” tagged with their qualifier @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton @AuthenticatedClient fun provideAuthOkHttp(): OkHttpClient = OkHttpClient.Builder().addInterceptor(AuthInterceptor()).build() @Provides @Singleton @PublicClient fun providePublicOkHttp(): OkHttpClient = OkHttpClient.Builder().build() } // 3. Inject by qualifier โ€” Hilt knows which one to provide class ProductApiService @Inject constructor( @AuthenticatedClient private val client: OkHttpClient ) class ImageLoader @Inject constructor( @PublicClient private val client: OkHttpClient )

Testing with Hilt

The main testing benefit of DI is that you can replace any real dependency with a fake one. Hilt provides first-class support for this with @TestInstallIn โ€” which replaces a production module with a test module across all tests โ€” and @UninstallModules + @BindValue for per-test overrides.

๐Ÿ“‹ Hilt test replacement โ€” swap the real thing for a fake
Production RepositoryModule @Binds ProductRepositoryImpl ViewModel gets real Repo impl real network calls โ† real DB โ‡„ swap module in tests Test TestRepositoryModule @Binds FakeProductRepository ViewModel gets fake Repo impl instant responses โ† no network FakeProductRepository returns hardcoded data instantly no network needed
Hilt testing โ€” replacing modules and injecting fakes
// FakeProductRepository โ€” instant, deterministic, no network class FakeProductRepository : ProductRepository { var products = listOf(Product("p1", "Laptop", 999.0)) override fun getProduct(id: String) = flowOf(products.first { it.id == id }) override suspend fun addToCart(id: String) = "cart-123" } // TestModule โ€” replaces RepositoryModule for ALL tests in this test class @TestInstallIn( components = [SingletonComponent::class], replaces = [RepositoryModule::class] ) @Module abstract class TestRepositoryModule { @Binds @Singleton abstract fun bindFakeRepository(fake: FakeProductRepository): ProductRepository } // Test class โ€” Hilt injects the fake automatically @HiltAndroidTest class ProductViewModelTest { @get:Rule val hiltRule = HiltAndroidRule(this) @get:Rule val mainDispatcher = MainDispatcherRule() @Inject lateinit var fakeRepo: FakeProductRepository @Before fun setup() = hiltRule.inject() @Test fun `loading shows Success state`() = runTest { val vm = ProductViewModel( getProduct = GetProductUseCase(fakeRepo), savedState = SavedStateHandle(mapOf("productId" to "p1")) ) val state = vm.uiState.value assertThat(state).isInstanceOf(ProductUiState.Success::class.java) } }

Common mistakes

Over-using field injection

Hilt supports field injection โ€” annotating a lateinit var inside a class with @Inject. It looks convenient, but it's worse than constructor injection in almost every way. With field injection: dependencies aren't visible from the constructor signature, fields are nullable until injected (making it easy to use them before injection), and you can't easily inject fakes in unit tests without running Hilt. Use constructor injection everywhere you can. Field injection is only necessary for classes you don't construct yourself โ€” Activities, Fragments, Services.

Injecting Activity Context into Singleton-scoped objects

A @Singleton object lives for the app's lifetime. An Activity is destroyed on rotation. If you inject Activity Context into a singleton, the singleton holds a reference to a destroyed Activity โ€” a classic memory leak. Hilt provides @ApplicationContext and @ActivityContext qualifiers. Use @ApplicationContext for singleton-scoped objects, and only use @ActivityContext inside Activity-scoped or shorter-lived components.

Context injection โ€” always use the right qualifier
// โŒ Don't do this โ€” Context could be an Activity (memory leak in a @Singleton) class AppDatabase @Inject constructor(private val context: Context) // โœ… Do this โ€” @ApplicationContext is always the Application, safe in @Singleton class AppDatabase @Inject constructor( @ApplicationContext private val context: Context ) // Or use the @Provides function pattern in a module: @Provides @Singleton fun provideDatabase(@ApplicationContext app: Context): AppDatabase = Room.databaseBuilder(app, AppDatabase::class.java, "app.db").build()

Making everything @Singleton

Singleton scope means one instance for the entire app lifetime. Overusing it has real costs: memory that's never released, state that's harder to reason about, and initialization that all happens up front. Use @Singleton for genuinely shared infrastructure โ€” network client, database, repositories. For things scoped to a screen, use @ViewModelScoped. For truly stateless helpers, use no scope at all.

Assisted Injection โ€” runtime + injected params together

Sometimes a class needs both injected dependencies and parameters that only exist at runtime โ€” things you can't know at compile time, like a product ID entered by the user, or a chat room ID passed via deep link. @AssistedInject solves this: Hilt injects what it knows about (the repository, the API service) while you supply the runtime values through a generated factory.

@AssistedInject โ€” mixing injected and runtime parameters
// 1. Annotate the constructor โ€” @Inject for injected params, @Assisted for runtime ones class ChatViewModel @AssistedInject constructor( private val repo: ChatRepository, // injected by Hilt private val analytics: AnalyticsLogger, // injected by Hilt @Assisted private val roomId: String // supplied at runtime ) : ViewModel() { // 2. Define the factory interface inside the class @AssistedFactory interface Factory { fun create(roomId: String): ChatViewModel } } // 3. Inject the factory into your Fragment โ€” Hilt provides it automatically @AndroidEntryPoint class ChatFragment : Fragment() { @Inject lateinit var factory: ChatViewModel.Factory private val roomId by navArgs<ChatFragmentArgs>() private val vm: ChatViewModel by viewModels { // Use the assisted factory as a ViewModelProvider.Factory viewModelFactory { initializer { factory.create(roomId.id) } } } } // In Compose with Hilt: @Composable fun ChatScreen(roomId: String) { val factory = hiltViewModel<ChatViewModel>() // won't work directly with @Assisted // Use assisted factory from EntryPointAccessors or pass via nav arg into SavedStateHandle }
โœ… Assisted vs SavedStateHandle

If the runtime parameter is a navigation argument (product ID, user ID from deep link), prefer SavedStateHandle โ€” Hilt fills it automatically in @HiltViewModel constructors and it survives process death. Use @AssistedInject for runtime values that are not navigation arguments and aren't part of saved state โ€” like a live WebSocket token or a config object created at call time.

@EntryPoint โ€” injecting into classes Hilt doesn't own

Hilt can inject into Activities, Fragments, ViewModels, and Services automatically. But some Android classes it doesn't manage โ€” ContentProvider, legacy utility classes, or any class you can't annotate with @AndroidEntryPoint. For these, you define an entry point: an interface that declares what you want, installed into a Hilt component. You then retrieve it manually using EntryPointAccessors.

@EntryPoint โ€” accessing Hilt bindings from unmanaged classes
// 1. Declare what you need as an @EntryPoint interface @EntryPoint @InstallIn(SingletonComponent::class) interface AnalyticsEntryPoint { fun analyticsLogger(): AnalyticsLogger fun crashReporter(): CrashReporter } // 2. Access it from any non-Hilt class using EntryPointAccessors class LegacyHelper(private val context: Context) { private val analytics: AnalyticsLogger by lazy { EntryPointAccessors .fromApplication(context, AnalyticsEntryPoint::class.java) .analyticsLogger() } } // 3. Common real-world case: ContentProvider class AppContentProvider : ContentProvider() { private val db: AppDatabase by lazy { @EntryPoint @InstallIn(SingletonComponent::class) interface DbEntryPoint { fun db(): AppDatabase } EntryPointAccessors .fromApplication(context!!, DbEntryPoint::class.java) .db() } }

@HiltWorker โ€” injecting into WorkManager

WorkManager creates Worker instances itself, so you can't use a normal @Inject constructor. Hilt has a dedicated solution: @HiltWorker combined with @AssistedInject. The worker gets its Hilt dependencies injected alongside the standard Context and WorkerParameters that WorkManager provides.

@HiltWorker โ€” dependencies injected into WorkManager workers
// 1. Annotate the Worker with @HiltWorker and use @AssistedInject @HiltWorker class SyncWorker @AssistedInject constructor( @Assisted context: Context, @Assisted params: WorkerParameters, private val repo: ProductRepository, // injected by Hilt private val analytics: AnalyticsLogger // injected by Hilt ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { return try { repo.syncAll() analytics.track("sync_success") Result.success() } catch (e: Exception) { Result.retry() } } } // 2. Initialize WorkManager with Hilt's WorkerFactory in your Application @HiltAndroidApp class MyApp : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory override val workManagerConfiguration get() = Configuration.Builder() .setWorkerFactory(workerFactory) .build() } // 3. Disable default WorkManager initializer in AndroidManifest.xml // <provider android:name="androidx.startup.InitializationProvider" // tools:node="remove" />

Multi-bindings โ€” injecting collections of dependencies

Sometimes you want to provide a set or map of implementations โ€” for example, a list of analytics providers where each feature can register its own, or a map of screen validators keyed by screen name. Multi-bindings let different modules contribute to a single Set<T> or Map<K, V> without any module knowing about the others. This is how plugin-style architectures are built with Hilt.

Multi-bindings โ€” @IntoSet and @IntoMap
// โ”€โ”€ @IntoSet: each @Provides contributes one element to a Set<T> โ”€โ”€ @Module @InstallIn(SingletonComponent::class) object AnalyticsModule { @Provides @IntoSet fun provideFirebaseAnalytics(): AnalyticsTracker = FirebaseAnalyticsTracker() @Provides @IntoSet fun provideMixpanel(): AnalyticsTracker = MixpanelTracker() } // Inject the full Set โ€” Hilt assembles it from all @IntoSet contributions class CompositeAnalytics @Inject constructor( private val trackers: Set<@JvmSuppressWildcards AnalyticsTracker> ) { fun track(event: String) = trackers.forEach { it.track(event) } } // โ”€โ”€ @IntoMap: contribute to a Map<Key, Value> โ”€โ”€ // First define a key annotation @MapKey annotation class ScreenKey(val value: String) @Module @InstallIn(SingletonComponent::class) object ValidatorModule { @Provides @IntoMap @ScreenKey("checkout") fun provideCheckoutValidator(): ScreenValidator = CheckoutValidator() @Provides @IntoMap @ScreenKey("profile") fun provideProfileValidator(): ScreenValidator = ProfileValidator() } // Inject the map โ€” look up validator by screen name at runtime class ValidationService @Inject constructor( private val validators: Map<String, @JvmSuppressWildcards ScreenValidator> ) { fun validate(screen: String, data: Any) = validators[screen]?.validate(data) }

Lazy<T> and Provider<T> โ€” deferred and repeated creation

By default, Hilt creates a dependency the moment it's needed. Two wrappers let you change this behaviour. Lazy<T> defers construction until the first time you call .get() โ€” and then caches the result. Provider<T> gives you a factory: every call to .get() returns a new instance (ignoring any scope on the binding). Both are injected exactly like any other dependency โ€” just wrap the type.

Lazy and Provider โ€” when and how to use them
class ReportService @Inject constructor( // Lazy: HeavyPdfRenderer is only created if generateReport() is actually called private val pdfRenderer: Lazy<HeavyPdfRenderer>, // Provider: each call to .get() returns a fresh ReportBuilder instance private val reportBuilderProvider: Provider<ReportBuilder> ) { fun generateReport(data: ReportData): ByteArray { val builder = reportBuilderProvider.get() // fresh instance every time builder.populate(data) return pdfRenderer.get().render(builder) // HeavyPdfRenderer created once, here } fun healthCheck() { // pdfRenderer.get() is never called here โ€” HeavyPdfRenderer is never constructed } } // Practical use: Lazy is great for expensive @Singleton objects that are only needed // in certain code paths. Avoids paying the init cost on every app start. // Provider is great when you explicitly need unscoped, fresh instances on demand.

How this connects to system design interviews

DI comes up in system design interviews in two ways. First, interviewers expect you to describe how your architecture is wired โ€” and "I'd use Hilt to inject the Repository into the ViewModel, with a Singleton-scoped Retrofit provided by a NetworkModule" is a much stronger answer than "I'd just pass things in somehow." Second, testability questions often hinge on DI: "How would you test the checkout flow without hitting the real payment API?" The answer is always "inject a fake PaymentRepository through the same Hilt binding, replacing the real module with a test module."

The deeper point is that DI is what makes everything else work. Clean Architecture, MVVM, testable Use Cases โ€” all of these depend on the ability to swap implementations. A Repository interface is meaningless if the ViewModel constructs its own ProductRepositoryImpl. Hilt is the mechanism that turns architectural intentions into architectural reality.

๐ŸŽฏ Interview Summary

DI principle โ€” classes declare dependencies, don't construct them. Enables swapping real implementations for fakes in tests.
@AssistedInject โ€” mix injected deps with runtime params (e.g. roomId). Use SavedStateHandle instead when it's a nav argument.
@EntryPoint โ€” access Hilt bindings from classes Hilt doesn't manage (ContentProvider, legacy helpers). Use EntryPointAccessors.fromApplication().
@HiltWorker โ€” inject deps into WorkManager workers. Requires HiltWorkerFactory in Application and disabling default WorkManager init.
Multi-bindings โ€” @IntoSet assembles a Set<T>, @IntoMap assembles a Map<K,V> from contributions across modules. Good for plugin registries.
Lazy<T> โ€” defers creation until first .get(), then caches. Provider<T> โ€” new instance on every .get(), ignores scope.
@HiltAndroidApp โ€” on Application. Triggers Hilt's code generation.
@AndroidEntryPoint โ€” on Activity/Fragment/Service. Enables field injection.
@HiltViewModel + @Inject constructor โ€” ViewModel gets all deps injected automatically, including SavedStateHandle.
@Provides โ€” function body creates the instance. For third-party types.
@Binds โ€” maps interface to implementation. More efficient than @Provides.
@InstallIn โ€” determines which component (and thus lifetime) a module belongs to.
Scopes โ€” @Singleton (app), @ViewModelScoped (screen), unscoped (new each time).
@TestInstallIn โ€” replaces a production module with a test module across all tests.
@ApplicationContext vs @ActivityContext โ€” use @ApplicationContext in @Singleton objects to avoid memory leaks.