Scoping for Feature Modules Without a God Graph
Everything ends up @Singleton because @Singleton always works. What that costs in lifetime bugs and lost module boundaries, how to pick a scope by asking when an object should die, and the api/impl split that makes feature isolation real.
Hessam Rastegari
AI Native Engineer · 12 years shipping Android
TL;DR — If most of your Hilt modules are @InstallIn(SingletonComponent::class), your dependency graph
is a global registry with constructor injection. It happens by default rather than by decision, because
@Singleton always compiles. The cost: objects that outlive their purpose, logouts that don't clear state,
and module boundaries that mean nothing. Fix it by asking "when should this die?" per binding —
@ActivityRetainedScoped is the right default for feature state and the least-used scope in Hilt. Then split
each feature into :api (interfaces) and :impl (bindings) so isolation is enforced by the compile
classpath rather than by discipline.
How everything became a singleton
Nobody decided this. It's an emergent property of the incentives:
@Singletonalways works. Any component can inject it.- No other scope is guaranteed to be available where you need it, and getting it wrong is a build error you'd have to think about.
- Nobody has ever been blocked in code review for over-scoping. It isn't visible in a diff.
So AppModule.kt reaches 900 lines and every feature's dependencies live in it. The path of least
resistance is a global.
What it actually costs
Objects outlive their purpose
@Singleton
class CheckoutSessionManager @Inject constructor() {
var cart: Cart? = null
var selectedAddress: Address? = null
}
That's process-lifetime state for a flow the user abandoned twenty minutes ago. It holds memory, and — worse — it holds stale state that a later flow will find and trust.
The logout that doesn't log out
The classic production bug in this family:
User A logs out. User B logs in on the same device.
User B sees User A's order history for two seconds.
Because OrderRepository is @Singleton, holds an in-memory cache, and nothing clears it — the object's
lifetime was never matched to the session's lifetime. It's a privacy incident produced by a scoping default.
Module boundaries stop meaning anything
You split the app into forty modules for build times and clarity. But if every feature's classes are in the
singleton graph, :feature:search can inject :feature:checkout's internals. The compile classpath allows
it, so eventually someone does, and the dependency diagram in your design doc becomes fiction.
Startup cost
Not everything in the singleton component is eager — Hilt is lazy by default. But anything reachable from an eagerly-injected entry point gets constructed at startup, and that graph is usually larger than anyone thinks.
Pick the scope by asking when it should die
One question per binding.
| Scope | Lives as long as | Belongs here |
|---|---|---|
@Singleton |
The process | OkHttpClient, Room, DataStore, WorkManager |
@ActivityRetainedScoped |
The activity, across config changes | Feature state, in-flight flows, per-screen-graph coordination |
@ViewModelScoped |
One ViewModel | Per-screen use cases holding state |
@ActivityScoped |
One activity instance | Rarely correct — dies on rotation |
| unscoped | Per injection | Everything stateless. The correct default. |
Two things worth calling out.
Unscoped is the right default for stateless objects. A use case with no fields costs nothing to construct. Scoping it adds a lock and a lifetime you have to reason about, in exchange for saving an object allocation. Don't.
@ActivityRetainedScoped is the underused one. It survives configuration changes but dies with the
activity. That's the lifetime of most "feature-level" state — a multi-step checkout, an in-progress
onboarding, a filter set shared by two screens in one nav graph. Teams reach for @Singleton because they
know rotation would break @ActivityScoped, without noticing there's a scope in between that does exactly
what they want.
@Module
@InstallIn(ActivityRetainedComponent::class)
object CheckoutModule {
@Provides
@ActivityRetainedScoped
fun session(): CheckoutSession = CheckoutSession()
}
The api/impl split
Scoping alone won't restore your module boundaries — the classpath will. Split each feature in two:
:feature:checkout:api interfaces, models, navigation routes
:feature:checkout:impl implementations + the Hilt module
// :feature:checkout:api
interface CheckoutLauncher {
fun route(orderId: String): String
}
// :feature:checkout:impl
@Module
@InstallIn(SingletonComponent::class)
abstract class CheckoutBindings {
@Binds
abstract fun launcher(impl: DefaultCheckoutLauncher): CheckoutLauncher
}
// :feature:search/build.gradle.kts
implementation(project(":feature:checkout:api")) // interfaces only
// impl is NOT on the classpath — reaching in won't compile
Only :app depends on the impl modules, and only to assemble the graph.
Three things fall out of this that make it worth the extra module:
- Isolation is enforced by the compiler, not by review discipline. You cannot accidentally couple two features.
- Gradle parallelises the impl modules, because nothing depends on them. On a large app this is a real build-time win.
- Testing gets a clean seam:
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [CheckoutBindings::class],
)
abstract class FakeCheckoutBindings {
@Binds abstract fun launcher(impl: FakeCheckoutLauncher): CheckoutLauncher
}
One annotation swaps an entire feature for a fake.
Should you write custom components?
Usually not. Hilt supports @DefineComponent for a genuinely custom lifetime — a "logged-in user" scope is
the common motivation — but it's more machinery than most apps need, and it doesn't compose with Hilt's
built-in components as smoothly as you'd hope.
For the session case specifically, an explicit SessionHolder that you clear on logout is simpler and easier
to reason about than a custom component. Make clearing it a deliberate, tested action rather than an
emergent property of the graph:
@Singleton
class SessionScope @Inject constructor(
private val caches: Set<@JvmSuppressWildcards Clearable>,
) {
fun onLogout() = caches.forEach { it.clear() }
}
A multibinding of Clearable means a new cache added next year is cleared automatically — the failure mode
becomes "somebody forgot to implement an interface", which is visible, rather than "somebody forgot to add a
line to a function", which isn't.
The ten-minute audit
Do this today:
# How much of your graph is global?
rg -c "SingletonComponent::class" --type kotlin | sort -t: -k2 -rn
Then the one that matters:
Log out of your app, then ask which objects still hold the previous user's data.
Every one is a scope you chose by accident. Fix those first — they're not just architecture, they're the ones that leak one user's data to the next.
The rule
Scope is a statement about lifetime, not a way to make the injection compile. Ask when each object should
die, use @ActivityRetainedScoped for the feature state you've been making global, keep stateless things
unscoped, and let the api/impl split enforce the boundaries your module list is already claiming.