Hilt Scopes & Components Explained

ARCHITECTURE › Dependency Injection

Every Hilt binding starts out unscoped. With no scope annotation at all, Hilt does not cache anything: it builds a brand new instance every single time that binding is requested, whether that means two different injection sites or the same class injected twice into the same object graph. For a stateless helper or an immutable value that is often exactly what you want. The trouble starts when a developer expects one shared instance without asking for it explicitly. Scoping annotations like @Singleton or @ViewModelScoped are what change this default: they tell Hilt to build the instance once for the lifetime of a particular component, then hand that same instance back to every later request within it. The rest of this lesson is about picking the right component, and therefore the right lifetime, for each binding.

class Logger @Inject constructor()
// two separate injections of Logger produce two separate instances

Hilt organizes its components into a tree, not a flat list. SingletonComponent sits at the root, created at Application#onCreate() and destroyed only when the process itself dies. Below it hang ActivityRetainedComponent, then ActivityComponent, then ViewComponent and FragmentComponent as children of the Activity, and ViewModelComponent alongside them. ServiceComponent hangs off the root too. This tree shape controls more than lifetime, it also controls visibility. A binding installed in a given component is reachable from that component and from every child underneath it, but never from a parent, and never from a sibling branch. So a binding declared for ActivityComponent is visible to that Activity's Fragments and Views, because they are its children, but it is invisible to SingletonComponent above it and to a completely different Activity's subtree beside it.

@Module
@InstallIn(ActivityComponent::class)
object ActivityModule {
    @Provides
    fun provideAnalytics(@ActivityContext ctx: Context): Analytics = Analytics(ctx)
}

Two components look like they should be interchangeable but have very different lifetimes, and interviewers ask about this pair constantly because mixing them up produces visible bugs. ActivityComponent is torn down and rebuilt on every configuration change: rotate the screen and you get a brand new Activity plus a brand new ActivityComponent, and anything scoped @ActivityScoped is discarded and rebuilt with it. ActivityRetainedComponent is different: it is created once, at the very first onCreate of that Activity, and destroyed only at the Activity's final onDestroy, the one that happens when the user actually leaves the screen rather than just rotating it. Anything scoped @ActivityRetainedScoped therefore survives rotation intact. Get this backwards and you either lose state you meant to keep across rotation, or you hang onto state longer than you intended.

@ActivityRetainedScoped
class FlowCoordinator @Inject constructor()
// same instance before and after rotation

@ActivityScoped
class ScreenAnimator @Inject constructor()
// a new instance after every rotation

@ViewModelScoped is often misread as 'share this across the whole feature', but it is narrower than that. It shares one instance across everything injected into a single ViewModel instance, but a different ViewModel instance, even one created moments later on the same screen flow, gets its own separate instance. If CheckoutViewModel and SummaryViewModel both depend on a @ViewModelScoped CartRepository, each ViewModel builds and owns its own CartRepository, they are not the same object, so writes made through one will not be visible through the other. That distinction, per-ViewModel rather than shared-across-ViewModels, is exactly what interviewers probe when they describe two ViewModels that need to see the same data and ask what goes wrong.

@ViewModelScoped
class CartRepository @Inject constructor()

@HiltViewModel
class CheckoutViewModel @Inject constructor(
    private val cart: CartRepository
) : ViewModel()

When you genuinely need one shared instance across multiple ViewModels on the same screen flow, for example a multi-step wizard where every step's ViewModel should read and write the same in-progress state, @ViewModelScoped is the wrong tool, because it deliberately gives each ViewModel its own copy. The fix is to scope to a component that all of those ViewModels descend from. ViewModelComponent sits below ActivityRetainedComponent in the tree, so a binding scoped @ActivityRetainedScoped is visible to, and shared by, every ViewModel in that Activity's flow, and it also survives configuration changes along the way. @Singleton would also work, but it is wider than necessary, it shares the instance with the entire app, not just this screen flow.

@ActivityRetainedScoped
class WizardRepository @Inject constructor()

@HiltViewModel
class StepOneViewModel @Inject constructor(private val repo: WizardRepository) : ViewModel()
// StepTwoViewModel injects the same repo instance, and it survives rotation

Hilt's @HiltViewModel-annotated ViewModels are not provided by ActivityComponent or FragmentComponent, they get their own component, ViewModelComponent, which tracks the ViewModel's own lifecycle rather than the screen's. That means a ViewModelComponent instance is created when the ViewModel is first built and torn down only when the ViewModel itself is cleared, so like ActivityRetainedComponent, it survives configuration changes. In Jetpack Compose you retrieve one by calling hiltViewModel(), which by default scopes the ViewModel to the current navigation destination. Anything you inject with @ViewModelScoped lives inside that ViewModelComponent, so it is created and destroyed on exactly the same schedule as the ViewModel that owns it.

@Composable
fun CartScreen(viewModel: CartViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsState()
}

Here is the classic scoping bug, and interviewers ask it precisely because it is easy to write without noticing. SingletonComponent lives for the entire process, so anything scoped @Singleton outlives every Activity that will ever exist in the app. If a @Singleton-scoped class stores an Activity Context, perhaps injected via @ActivityContext, then that Context, and the whole view hierarchy it points to, cannot be garbage collected after the Activity is destroyed. The Activity is leaked for the rest of the app's life. This is not a rare edge case, it is what happens the moment someone scopes something too widely just to avoid re-fetching a dependency, then hands it an Activity-scoped Context because that was the Context available at the injection site.

@Singleton
class BadAnalytics @Inject constructor(
    @ActivityContext private val ctx: Context
)

The fix for that leak is almost always the same, and it is worth knowing the exact qualifier names because interviewers will ask you to name them. Hilt predefines two Context qualifiers: @ApplicationContext, which injects the application-wide Context that lives for the whole process, and @ActivityContext, which injects the Context of a specific Activity and should only be used by bindings scoped no wider than that Activity. Since the application Context never dies alongside any particular Activity, it is always safe to store inside a @Singleton. The rule of thumb: anything scoped @Singleton or @ActivityRetainedScoped should ask for @ApplicationContext, never @ActivityContext, and anything scoped narrower than an Activity can safely take @ActivityContext if it genuinely needs Activity-specific behaviour.

@Singleton
class PushHelper @Inject constructor(
    @ApplicationContext private val context: Context
)

Components don't only track Activities and ViewModels, a bound Service gets one too. ServiceComponent is created at that Service's onCreate and destroyed at its onDestroy, and a binding annotated @ServiceScoped, installed in a module targeting ServiceComponent, is cached for exactly that lifetime: one instance for as long as the Service is alive, a fresh one if the Service is recreated. This matters for things like a Bluetooth connection or a media session that should live and die with the Service hosting it, rather than being rebuilt on every injection or, worse, scoped so widely that it outlives the Service and leaks a system resource.

@Module
@InstallIn(ServiceComponent::class)
object ServiceModule {
    @ServiceScoped
    @Provides
    fun provideConnection(): BleConnection = BleConnection.create()
}

Two build-time guardrails are worth memorizing because they turn scoping mistakes into compile errors instead of runtime bugs. First, every @Module must declare @InstallIn naming the component it belongs to, omit it and the build fails outright, Hilt will not guess where a module's bindings should live. Second, once a module is installed somewhere, any scope annotation used inside it must actually belong to that component. @ActivityScoped only exists as a scope on ActivityComponent, so writing it on a binding installed in SingletonComponent is a mismatch Hilt catches immediately, it does not silently ignore the annotation or fall back to something else, it fails the build. Both checks exist for the same reason: to make sure a binding's declared lifetime always matches a real component.

// missing @InstallIn entirely also fails the build
@Module
object BrokenModule {
    @Provides fun provideRepo(): MyRepo = MyRepo()
}

Not every Android framework class gets its own Hilt component. BroadcastReceiver is the clearest example: Hilt does not generate a BroadcastReceiverComponent at all. Mark a receiver @AndroidEntryPoint and Hilt performs members injection directly from SingletonComponent, skipping the rest of the tree entirely. The practical consequence is that a BroadcastReceiver can only receive bindings that are @Singleton or unscoped, anything scoped to an Activity, a ViewModel, or a Service simply is not reachable from there, because none of those components are ancestors of what a BroadcastReceiver injects from.

@AndroidEntryPoint
class AlarmReceiver : BroadcastReceiver() {
    @Inject lateinit var scheduler: AlarmScheduler
}

A plain @AndroidEntryPoint custom View is injected through ViewComponent, which sits under ActivityComponent, so it can only see Activity-level and app-level bindings, not anything scoped to the specific Fragment hosting that View. If the View genuinely needs a Fragment-scoped binding, add @WithFragmentBindings alongside @AndroidEntryPoint. That swaps the View onto a different generated component, ViewWithFragmentComponent, which sits under FragmentComponent instead, and can therefore see everything the hosting Fragment can see. Forgetting that annotation is why a working @FragmentScoped binding suddenly fails to inject the moment it is used from inside a custom View.

@WithFragmentBindings
@AndroidEntryPoint
class FragmentAwareView(ctx: Context) : LinearLayout(ctx) {
    @Inject lateinit var fragmentDep: FragmentDep
}

ContentProvider is another class Hilt cannot wire up as a normal entry point, because a provider can be instantiated before Application#onCreate even runs, too early for the usual injection machinery. For this, and any other class outside Hilt's standard entry points, the idiomatic escape hatch is an @EntryPoint-annotated interface: declare the bindings you need as interface methods, install it in the component that has them, typically SingletonComponent, and then fetch it at runtime with EntryPointAccessors.fromApplication(context, YourEntryPoint::class.java). That gives you a manual, explicit way to pull a binding out of the graph from a class Hilt could never annotate directly.

@EntryPoint
@InstallIn(SingletonComponent::class)
interface MyProviderEntryPoint {
    fun myRepository(): MyRepository
}

Back to Hilt Scopes & Components