8 min read

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.

Dependency InjectionHiltAndroidArchitectureModularization
HR

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:

  • @Singleton always 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.

Keep reading