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 else

Gradle 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 :app and lives in the feature module → the entry point stops referencing any feature

  • Add 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:

If something is missing from this list, please reply to this email or leave a comment.

Reply

Avatar

or to participate

Keep Reading