When we audit an Android project, AppModule.kt is one of the first files we open. Its length tells us more about the rest of the codebase than most architecture documents do. Nine hundred lines, 80 @Provides functions, @Singleton on every one of them: when we see that, we know the team uses DI and does not manage its dependencies. The container is installed, the graph compiles, the app launches. And still nobody can say how long objects live, who knows about whom, or what stays in memory after logout.
This article covers the Android side: Kotlin, Hilt/Dagger and Koin. For the Swift version of the same diseases, see the iOS article. The examples use a hypothetical travel booking app: flight search, booking, payment, profile.
A realistic "before"
Here is a scaled-down version of a picture we see often in large codebases:
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides @Singleton
fun provideAuthInterceptor(prefs: SharedPreferences): Interceptor {
val token = prefs.getString("token", null) // read once, when the graph is built
return Interceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
)
}
}
@Provides @Singleton
fun provideBookingRepository(api: BookingApi, dao: BookingDao): BookingRepository =
BookingRepositoryImpl(api, dao)
@Provides @Singleton
fun provideSearchFlightsUseCase(repo: FlightRepository): SearchFlightsUseCase =
SearchFlightsUseCase(repo)
@Provides @Singleton
fun provideProfileCache(): ProfileCache = ProfileCache()
@Provides @Singleton
fun providePaymentManager(api: PaymentApi): PaymentManager = PaymentManager(api)
// ... 75 more
}
class PaymentManager(private val api: PaymentApi) {
private var activity: Activity? = null
private var bookingId: String? = null
fun init(activity: Activity, bookingId: String) { // forget to call it and you get an NPE
this.activity = activity
this.bookingId = bookingId
}
}
class PriceFormatter {
fun format(context: Context, amount: Money): String {
val entryPoint = EntryPointAccessors.fromApplication(
context.applicationContext, FormatterEntryPoint::class.java
)
return entryPoint.currencyRepository().format(amount)
}
}
None of this is a strawman; every line has a reasonable history. @Singleton was added "to be safe", init() appeared because "we needed the Activity", and EntryPointAccessors crept in because "adding a constructor parameter meant touching ten files". The result is four separate classes of bug: wrong lifetime, a leaked Activity, hidden dependencies, and a giant module nobody owns. We will take them in order.
@Singleton on everything throws the lifetime decision away
A scope is a decision about how long an object lives. Writing @Singleton on every binding means you never made that decision; you just made "until the process dies" the default. That has three concrete costs.
The first is memory. A scoped object in SingletonComponent is held until the process is destroyed. The helper classes for a payment flow the user opened once are still on the heap hours after the flow closed. The Hilt documentation is explicit that scoping is not free: a scoped binding costs generated code size and runtime performance, and the advice is to scope when correctness requires it, and not as an optimisation. @Singleton buys a stateless SearchFlightsUseCase nothing.
The second is session lifetime. provideAuthInterceptor above reads the token once, when the graph is built. When the user signs out and signs in with another account, the interceptor still carries the old token. ProfileCache likewise keeps the previous user's name and saved cards in memory. A large share of logout bugs come from this shape: user-owned state is spread across application-lifetime objects, and logout depends on someone remembering to clear each one. The first cache nobody remembered becomes the ticket titled "logged out, still saw the other account's bookings".
The third is testing. Singleton state carries over between tests, which makes it the usual suspect for order-dependent failures. We covered the general cost of singletons in a separate article; here the point is how a container hides that cost.
Choosing the right component
Hilt's components and their lifetimes, as documented:
| Component | Scope | Created at | Destroyed at |
|---|---|---|---|
SingletonComponent |
@Singleton |
Application#onCreate() |
Process destroyed |
ActivityRetainedComponent |
@ActivityRetainedScoped |
First Activity#onCreate() |
Last Activity#onDestroy() |
ViewModelComponent |
@ViewModelScoped |
ViewModel created | ViewModel destroyed |
ActivityComponent |
@ActivityScoped |
Activity#onCreate() |
Activity#onDestroy() |
FragmentComponent |
@FragmentScoped |
Fragment#onAttach() |
Fragment#onDestroy() |
ServiceComponent |
@ServiceScoped |
Service#onCreate() |
Service#onDestroy() |
ActivityRetainedComponent survives configuration changes; ActivityComponent is rebuilt on every rotation. The order we decide in:
- If the object holds no state, write no scope. An unscoped binding produces a new instance per request, which is the right answer for most use cases, mappers and validators.
- If the state belongs to one screen's job, use
@ViewModelScoped. A booking draft, or form state shared between wizard steps, goes here. - If several ViewModels under the same Activity share the state, use
@ActivityRetainedScoped. - If the object needs an
Activityreference, use@ActivityScopedinActivityComponent. - Keep
@Singletonfor what really is process-lifetime:OkHttpClient, the Room database, the analytics client, the session store.
Hilt has no ready-made "user scope" for user-bound state. You can define a custom component with @DefineComponent, but the Hilt documentation lists the price itself: every extra component adds cognitive overhead, the hierarchy is single-parent so you can get stuck on diamond dependencies, and you manage the component's lifetime yourself. For most apps a simpler rule is enough: all user-owned state lives under one object and dies with it.
data class Session(val userId: String, val accessToken: String)
@Singleton
class SessionStore @Inject constructor() {
private val _session = MutableStateFlow<Session?>(null)
val session: StateFlow<Session?> = _session.asStateFlow()
fun start(session: Session) { _session.value = session }
fun end() { _session.value = null }
}
class AuthInterceptor @Inject constructor(
private val sessionStore: SessionStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = sessionStore.session.value?.accessToken // read on every request
val request = chain.request().newBuilder().apply {
if (token != null) header("Authorization", "Bearer $token")
}.build()
return chain.proceed(request)
}
}
The interceptor no longer captures the value, it knows the source. User-bound caches either observe session and empty themselves when it becomes null, or include userId in their key. Logout shrinks to one line: sessionStore.end().
Activity and Context leak out of the wrong scope
Hilt provides two Context bindings: @ApplicationContext and @ActivityContext. The second lives in ActivityComponent, so if you put @ActivityContext in the constructor of a @Singleton class the graph does not compile. This is one of the rare moments the scope system actively protects you, and swapping the qualifier until the error goes away is the wrong way to silence it.
The leak usually arrives through a door the container cannot see: a setter. PaymentManager.init(activity, …) above plants an Activity inside a process-lifetime object. The screen closes, the Activity is destroyed, and the singleton keeps holding it along with its entire view hierarchy. LeakCanary will catch it; someone reading AppModule will not.
Koin has a sneakier form of the same bug. Pass an Activity to a single definition through parametersOf, and the first call's Activity is kept while the parameter on later calls is ignored, because the instance already exists:
val paymentModule = module {
// The first Activity is held forever
single { (activity: Activity) -> PaymentLauncher(activity, get()) }
}
The fix rests on the same idea in both frameworks: an object must not outlive what it holds.
@ActivityScoped
class PaymentLauncher @Inject constructor(
private val activity: Activity, // default binding of ActivityComponent
private val paymentApi: PaymentApi,
)
class ReceiptStorage @Inject constructor(
@ApplicationContext private val context: Context, // the application context is enough for files
)
Rule of thumb: if you need theming, inflation, a dialog or an activity result, you need an Activity and the class belongs to ActivityComponent. For files, preferences, databases and system services, @ApplicationContext is enough. The Koin equivalents are androidContext() and scoped definitions (activityScope and friends).
Service locator in disguise
Dependency injection gives you exactly one thing: what a class needs is written in its constructor. The moment you call the container from the middle of a class, that information is gone and what you have is a service locator, however modern the library it is written with.
Hilt: EntryPointAccessors everywhere
The Hilt documentation keeps the use case for entry points narrow: Android components Hilt does not support, and libraries that do not use Dagger. A ContentProvider, a class some library instantiates by reflection, a callback you do not construct. An ordinary class like PriceFormatter is not on that list. A class that calls EntryPointAccessors needs Hilt booted to be tested, and you have to read its body to learn what it depends on.
The fix is boring and correct:
class PriceFormatter @Inject constructor(
private val currencyRepository: CurrencyRepository,
) {
fun format(amount: Money): String = currencyRepository.format(amount)
}
When an entry point really is required, the documentation recommends declaring the interface inside the class that needs it. A shared AppEntryPoint with twenty methods is a polite name for a global variable.
Koin: KoinComponent on a domain class
Koin's DSL makes the slide into service location even easier than Hilt does:
class BookingRepository : KoinComponent {
private val api: BookingApi by inject()
private val dao: BookingDao by inject()
suspend fun confirm(draft: BookingDraft): Booking {
val pricing: PricingEngine = get() // resolved in the middle of a method
return api.confirm(draft.toRequest(pricing.quote(draft)))
}
}
This class now depends on Koin, needs startKoin to be tested, and if the PricingEngine definition is missing you find out the first time confirm() runs, which is when a user taps "Confirm". Koin's own documentation says the same: KoinComponent is for classes Koin does not create (framework-instantiated entry points, a BroadcastReceiver, callbacks you do not control) and should be kept at the edges, with constructor injection for everything below.
class BookingRepository(
private val api: BookingApi,
private val dao: BookingDao,
private val pricing: PricingEngine,
)
val bookingModule = module {
singleOf(::BookingRepository)
}
Checking a Koin graph without running it
Dagger turns a missing binding into a compile error. Koin does not give you that by default; you have to add it. The current way is the verify() API; the older checkModules() and checkKoinModules() are deprecated.
class KoinGraphTest {
@Test
fun `every definition has its dependencies`() {
appModule.verify(
extraTypes = listOf(HttpClientEngine::class) // types supplied at runtime
)
}
}
verify() does not instantiate anything; it reads the constructor of each definition and checks that every parameter type has a definition in the module tree. On Android, androidVerify() from koin-android-test adds types such as Context, Activity and SavedStateHandle for you. Know its limit too: according to the documentation, verify() reads constructors only; it does not check qualifiers or get() calls written inside a definition lambda or a class body. In other words, service-locator usage also blinds the one verification tool you have.
The Koin Compiler Plugin, which arrived with Koin 4.2, aims to close that gap at build time: it checks missing definitions, qualifier mismatches and get()/inject() call sites during compilation. It requires Kotlin 2.3.20 or later (K2). It is a young compiler plugin, so try it against your own Kotlin version before you hand CI over to it; until then, the verify() test should be a mandatory CI step.
Breaking up the god module
The real problem with an 80-@Provides AppModule is that it has no owner. Every feature team touches the same file, every merge conflicts, and because the file imports the implementation classes of every feature, no feature can compile on its own.
Most of those @Provides functions are unnecessary anyway. Our rule fits in three lines:
- The class is yours and has a constructor:
@Inject constructor. Nothing goes in a module. - You are binding an interface to an implementation:
@Binds. - The type is not yours or needs a builder (Retrofit, Room,
OkHttpClient):@Provides.
// Before: a hand-written factory
@Provides @Singleton
fun provideBookingRepository(api: BookingApi, dao: BookingDao): BookingRepository =
BookingRepositoryImpl(api, dao)
// After
class BookingRepositoryImpl @Inject constructor(
private val api: BookingApi,
private val dao: BookingDao,
) : BookingRepository
@Module
@InstallIn(SingletonComponent::class)
internal abstract class BookingDataModule {
@Binds
abstract fun bindBookingRepository(impl: BookingRepositoryImpl): BookingRepository
}
With the @Provides version, adding a constructor parameter changes two files; with @Binds, only the class itself. Dagger also has no factory body to generate for a @Binds method.
Deal with field injection in the same sweep. For an Activity, Fragment or Service marked @AndroidEntryPoint, @Inject lateinit var is unavoidable because the framework constructs those classes. In every other class, lateinit injection hides the dependency from the constructor, turns what could be a val into a var, and leaves the object in a "constructed but not yet usable" state.
The api/impl split in a multi-module build
Once the single AppModule is broken up, the pieces need somewhere to go. Our answer: each binding goes to the Gradle module where its implementation lives.
:app → depends on every impl module, @HiltAndroidApp lives here
:feature:booking:api → BookingRepository, BookingNavigator (interfaces, models)
:feature:booking:impl → BookingRepositoryImpl, screens, BookingDataModule (@InstallIn)
:feature:payment:api
:feature:payment:impl → depends on :feature:booking:api, NOT on :booking:impl
The module carrying @InstallIn stays inside impl and stays internal. :feature:payment:impl knows bookings through the BookingRepository interface; it cannot see BookingRepositoryImpl, so it cannot depend on it. When features can see each other's implementations, two things happen: every change in booking:impl recompiles payment:impl, and one day somebody skips the interface and uses the concrete class "because it was accessible". Assembling the graph is the job of :app. That is also exactly what Hilt requires: the Gradle module that compiles your Application class must have every Hilt-using module in its transitive dependencies.
The split has a price: it doubles the module count. Do not pay it in an app with three features; we discussed when it pays for itself in the cost of modularization.
The build side: KSP instead of kapt
According to the Dagger documentation, KSP support is stable as of Dagger 2.60 and KSP 2.3.9. kapt generates Java stubs from Kotlin code and runs Java annotation processors on those stubs, which is an extra compilation step in every module. There is no reason left to choose kapt for a new setup.
// feature/booking/impl/build.gradle.kts
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt) // com.google.dagger.hilt.android
}
dependencies {
api(projects.feature.booking.api)
implementation(libs.hilt.android) // com.google.dagger:hilt-android:2.60.1
ksp(libs.hilt.compiler) // com.google.dagger:hilt-compiler:2.60.1
}
Three things to watch during the move. The Hilt Gradle plugin requires AGP 9 from version 2.59 onwards, so projects still on AGP 8 have that to sort out first. KSP processors cannot see types generated by a kapt processor, so any other processor that generates classes Dagger inspects (Room, Moshi codegen and the like) must move to KSP as well. And migrate module by module, measuring the gain in your own project: comparing kapt* and ksp* task durations in ./gradlew :app:assembleDebug --scan before and after is enough.
Assisted injection for runtime parameters
PaymentManager.init(activity, bookingId) is a way to smuggle in a value the container does not know. Its flaw is that the object is built in two phases and the compiler does not enforce the second one. Dagger's assisted injection solves the same need in the constructor.
@HiltViewModel(assistedFactory = BookingDetailViewModel.Factory::class)
class BookingDetailViewModel @AssistedInject constructor(
@Assisted private val bookingId: String,
private val repository: BookingRepository,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(bookingId: String): BookingDetailViewModel
}
}
// In the Fragment
private val viewModel by viewModels<BookingDetailViewModel>(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback<BookingDetailViewModel.Factory> { factory ->
factory.create(requireArguments().getString("bookingId")!!)
}
}
)
bookingId is now a val, cannot be null, and the ViewModel cannot be created without it. In Compose, the hiltViewModel overload that takes a creation callback does the same job; the artifact it lives in has changed across androidx.hilt releases, so check the one you are on.
Know three constraints. @AssistedInject types cannot be scoped and cannot be injected directly, only their factories can. If two assisted parameters share a type, you must tell them apart with @Assisted("name") on both the constructor and the factory method. And the Hilt documentation warns against passing an Activity, Fragment or View through a ViewModel factory; that is the assisted edition of the leak from the previous section. For values you still need after process death, such as navigation arguments, SavedStateHandle remains the better address, because assisted parameters are not persisted.
In Koin the equivalent is parametersOf with a factory or viewModel definition; do not use it with single.
Most tests should never see DI
The least advertised benefit of constructor injection is that tests do not need a container:
class BookingRepositoryImplTest {
private val api = FakeBookingApi()
private val dao = InMemoryBookingDao()
private val repository = BookingRepositoryImpl(api, dao)
@Test
fun `a confirmed booking is written to the local database`() = runTest {
val booking = repository.confirm(sampleDraft)
assertEquals(booking, dao.find(booking.id))
}
}
No Hilt, no Robolectric, no test runner configuration. When we see @HiltAndroidTest on a unit test, we read it as a sign that the class under test does not take its dependencies through its constructor.
The tests that do need Hilt are UI and integration tests that exercise the graph itself. There, the recommended way to replace bindings is @TestInstallIn:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [BookingDataModule::class],
)
abstract class FakeBookingDataModule {
@Binds
abstract fun bindBookingRepository(fake: FakeBookingRepository): BookingRepository
}
This module applies to every test in the test source set. @UninstallModules and @BindValue let you change bindings for one test, but as the documentation points out, each test class that uses them gets its own generated component and build time goes up. Our default is @TestInstallIn; we use @BindValue when a fake really is specific to one test. Small modules pay off once more here: replaces works per module, so replacing the 80-binding AppModule means rewriting 80 bindings.
Metro and the other compile-time options
The neighbourhood around Dagger has been busy for the last two years, so a short status check. Square's Anvil is deprecated: per its README it will only be supported for K1, and development ends when K2 becomes a requirement. kotlin-inject is active but still carries a 0.x version number (0.9.0 at the time of writing). Metro combines ideas from Dagger, Anvil and kotlin-inject in a single Kotlin compiler plugin; it needs neither kapt nor KSP, offers Dagger interop, and has reached 1.x releases (1.4.5 at the time of writing). Because it is a compiler plugin, you are tied to the range of Kotlin versions it supports, and Kotlin upgrades have to be planned against Metro's compatibility table.
Our view: if you have a working Hilt setup and your problems are the anti-patterns in this article, switching tools fixes none of them. Marking everything @SingleIn(AppScope::class) is the same mistake as marking everything @Singleton. Fix the graph first; if you then have a concrete reason such as Kotlin Multiplatform or build time, evaluate the move.
A step-by-step clean-up plan
Do not do this in one large PR. Every step should be shippable on its own.
- Take inventory. Put the counts of
@Singleton,EntryPointAccessors,KoinComponent,by inject()and@Inject lateinit varoutside Android classes into a table. Those numbers are the only progress metric you will have. - Remove scopes from stateless classes. Use cases, mappers, formatters, validators. The risk is low: behaviour does not change, only lifetime gets shorter. For every remaining
@Singletonyou should be able to write a one-sentence answer to "why process lifetime?". - Collect session state in one place. Introduce something like
SessionStore, and tie the token and user-bound caches to it. Verify with a heap dump after logout that nothing belonging to the previous user is left. - Replace
@Provideswith@Inject constructorand@Binds. It is a mechanical change; one PR per module is enough. - Push service-locator calls to the edges.
EntryPointAccessorsandKoinComponentstay only in framework-instantiated classes. If you use Koin, add theverify()test to CI at the end of this step. - Turn
init()methods into assisted injection and move classes that hold anActivityintoActivityComponent. Keep LeakCanary on in debug builds. - Hand
AppModuleout to its owners. First split it into files within the same Gradle module, then move the pieces intoimplfor the features that have anapi/implsplit. - Move from kapt to KSP and compare timings with a build scan.
How you know it worked: the inventory numbers drop, most unit tests run without starting Hilt or Koin, the heap is clean after logout, and reading a constructor is enough to learn what a class depends on.
If you cannot read what a class needs from its constructor, what you have is global variables with annotations on them.
Checklist
- Every
@Singletonhas a concrete reason to live as long as the process; stateless classes are unscoped. - User-owned state hangs off a single session source; logout clears it with one call.
- No class that holds an
Activitylives inSingletonComponentor a Koinsingle; long-lived classes use@ApplicationContext. EntryPointAccessorsandKoinComponentappear only in framework-instantiated classes, never in domain or data classes.- Koin projects run
verify()(or the Compiler Plugin checks) as a required CI step. - Our own classes use
@Inject constructor, interfaces use@Binds, and@Providesis reserved for third-party types. - Field injection exists only in
@AndroidEntryPointclasses. - No module file carries bindings for more than one feature;
@InstallInmodules live inimpland areinternal. - Feature
implmodules do not depend on each other, only onapimodules. - Runtime parameters arrive through
@AssistedInjectorSavedStateHandle, not throughinit(). - Unit tests do not start a container;
@TestInstallInis the default in Hilt tests. - Annotation processing runs on KSP; no module is left on kapt.
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.