Brief
The what: A quick snapshot of the context
Whenever you display core flows to the user, you have a two choices:
Display a progress bar and let them wait
Preload the data to display it instantly
Whatever you peek come with consequences:
No preload → user inpatient, directly impacts revenue $$$, but simpler architecture
Has preload → user happy, but the architecture suffer
Most teams make the right product decision - preload the data.
But the consequences stay ignored.

You end up with classes having 50-100 dependencies.
Sometimes they’re visible, sometimes masked with wrapper classes like SynchDataService.
A nesting doll of GOD objects, with GOD object dependencies.
And every time you touch them, the app startup logic is about to explode.

🐞 The Issue: Startup logic growth indefinitely.

Breakdown
The when: Reverse engineering the cause and the cost
Whatever architecture you use, there's one thing unchanged - you need to make some requests when the app starts.
And that's where architecture breaks - the startup logic bottleneck.
Despite all the other business logic in the app, startup logic has mainly two places to run: Application and MainActivityViewModel.
Every feature that needs startup work funnels through one of them.
One line at a time
You need to preload purchase options - you did it.
class MainActivityViewModel @Inject constructor(
private val billingRepository: BillingRepository
) : ViewModel() {
private suspend fun syncData() = supervisorScope {
launch { billingRepository.loadPurchaseOptions() }
}
}You need to set up a new SDK - you did it.
class MyApplication : Application() {
@Inject
lateinit var appsFlyerService: AppsFlyerService
override fun onCreate() {
appsFlyerService.configure()
}
}Line by line. Dependency by dependency. The constructor stops fitting on screen.
So you put some dependencies in a wrapper and repeat.
class SyncDataService(...) {
suspend fun sync() = supervisorScope {
launch { configRepository.sync() }
launch { offerRepository.updateOffers() }
launch { promotionsRepository.getPromotions() }
}
}
class MainActivityViewModel @Inject constructor(
private val syncDataService: SyncDataService
) : ViewModel() {
init {
viewModelScope.launch {
syncDataService.sync()
}
}
}→ New app feature added. New startup logic added. But the entry point stays the same.
Why the wrapper doesn't help
Moving dependencies into SyncDataService feels like cleanup. It isn't.
The wrapper holds the dependencies now. The entry point holds the wrapper. Same coupling, one layer down.
You didn't remove anything. You hid the count.
→ The nesting doll gets another shell. The pile underneath is the same.
The module graph pays for it
Zoom out from the class. Both entry points live in one module: :app.
For MainActivityViewModel to reference BillingRepository, :app has to depend on :payments.
Every startup dependency you added didn't only grow a constructor. It pulled a whole module into :app.
Do it 50 times and :app depends on everything:
:app -> :payments
:app -> :configs
:app -> :user
:app -> :analytics
:app -> ...everything elseGradle compiles on dependencies. Change one line in :payments, and :app recompiles, plus everything :app drags behind it.
Every leaf change invalidates the center.
Your modules were supposed to build and ship independently. Now none of them do. They all route through the same center.
→ Modularization gave you boundaries. The startup bottleneck deleted them.

✅ The Fix: Set multibinding on a stable contract.

Guideline
The how: Implementation guideline and recommendations
The fix is to invert the bottleneck.
Right now every feature reaches into the entry point. Instead, every feature points at one stable contract, and the entry point stops knowing any of them.
Step 1 - Define the contract
A Startup Task is a piece of work you need to run during app launch.
It could be a UseCase with business logic, or a Repository method to invoke. They all have one thing in common: a single function to call.
So the contract looks like this:
fun interface StartupTask {
suspend operator fun invoke()
}🧠 Pro tip 1: use fun interface, not interface, so you can bind Repository methods directly without wrapping each one in a UseCase.
🧠 Pro tip 2: in a multi-module project put it in :common, where your utils already live, so every part of the app can see the contract.
Step 2 - Clean up the dependencies
Your :app module no longer needs a reference to any business-logic module, so it comes out:
:app -> :payments //❌ remove
:app -> :configs //❌ remove
:app -> :user //❌ remove
:app -> :analytics //❌ remove:app knows only the StartupTask contract, so it can launch the work wherever it's needed.
Step 3 - Bind the work
The UseCase binding.
Implement the StartupTask interface on the UseCase:
internal class RefreshOffersUseCase @Inject constructor(
private val offersRepository: OffersRepository,
private val configsRepository: ConfigsRepository,
private val abTestRepository: AbTestRepository,
private val userRepository: UserRepository,
) : StartupTask {
override suspend fun invoke() {
val userId = userRepository.getUser().id
val segment = abTestRepository.getSegment(userId)
val availableOffers = configsRepository.getOffers(segment)
offersRepository.refreshOffers(availableOffers)
}
}🧠 Pro tip: add the internal modifier. You don't need to expose the implementation outside the module.
@Module
@InstallIn(ViewModelComponent::class)
internal interface UseCaseModule {
@Binds
@IntoSet
fun bindRefreshOffersUseCase(impl: RefreshOffersUseCase): StartupTask
}⚠️ Don't use SingletonComponent. The tasks are invoked from MainActivityViewModel, so scope the visibility to ViewModelComponent.
⚠️ Watch the return type. It must be StartupTask, because that's the type of the multibound Set.
Now :app sees the task despite the internal modifier and without a direct reference.
The Repository binding.
Because the contract is a functional interface, you bind the Repository method to StartupTask directly in the DI module:
@Module
@InstallIn(ViewModelComponent::class)
object RepositoryModule {
@Provides
@IntoSet
fun provideConfigsStartupTask(
repository: ConfigsRepository,
): StartupTask = StartupTask(repository::sync)
}🧠 Pro tip: use a Kotlin method reference for cleaner binding syntax.
Step 4 - Fix the entry point
Now remove every dependency from the ViewModel and replace them with two lines:
@HiltViewModel
class MainActivityViewModel @Inject constructor(
private val startupTasks: Set<@JvmSuppressWildcards StartupTask>,
) : ViewModel() {
init {
startupTasks.onEach { task -> viewModelScope.launch { task.invoke() } }
}
}⚠️ Choose the proper CoroutineScope and error-handling strategy. The decision depends on how you want the tasks executed.
Step 5 - Categorize
In production apps there are usually three kinds of startup task:
Unauthorized. No user data or authentication required.
Authorized + blocking. Needs user data, must finish before the app launches.
Authorized + non-blocking. Needs user data, can run optimistically.
So you end up with three Sets and bind each task to the one it belongs in.
Create a qualifier for each type:
@Retention
@Qualifier
annotation class StartupTaskType {
@Retention
@Qualifier
annotation class UnAuthorized
@Retention
@Qualifier
annotation class Authorized {
@Retention
@Qualifier
annotation class Blocking
@Retention
@Qualifier
annotation class NonBlocking
}
}Update the binding with the right type annotation:
@Module
@InstallIn(ViewModelComponent::class)
internal interface UseCaseModule {
@Binds
@IntoSet
@StartupTaskType.Authorized.NonBlocking
fun bindRefreshOffersUseCase(impl: RefreshOffersUseCase): StartupTask
}Then inject that Set into the entry point and run the tasks the same way:
@HiltViewModel
class MainActivityViewModel @Inject constructor(
@StartupTaskType.Authorized.NonBlocking
private val startupTasks: Set<@JvmSuppressWildcards StartupTask>,
) : ViewModel() {
init {
if (userRepository.isAuthenticated) {
startupTasks.onEach { task -> viewModelScope.launch { task.invoke() } }
}
}
}⚠️ Don't miss the correct qualifiers during injection.
What flips:
The dependency leaves
:appand lives in the feature module → the entry point stops referencing any featureAdd a new feature → you write its task in its own module, the entry point never changes
Remove a feature → you delete one task binding, the entry point never changes
The entry point → 50 startup dependencies collapse into one injected Set
→ The logic didn't disappear. It moved to the module that owns it, off the entry point's back.

One question before you close this.
Everyone says the same thing. "I'm confused about Clean Architecture."
Too perfect in theory. Too vague to act on.
To help YOU, I'll build a few issues about this.
So tell me where you get stuck:
What part of Clean Architecture confuses you most?
- Where does this logic go — presentation, domain, or data?
- When do I need a Use Case vs. just a Repository?
- Why so many models (DTO, Entity, Domain, UI)?
- Clean Architecture vs. Modularization — what's the difference?
- Repository vs. DataSource — what's the difference?
- I copy tutorials without knowing why they're built that way
- Other (reply and tell me)
If something is missing from this list, please reply to this email or leave a comment.
