The Android Startup Pattern: A Lifecycle-Aware, Multi-Module Approach

A Clean, DI-Driven Architecture for Managing Cold Starts, Background Wakeups, and Privacy Compliance in Modern Android Apps

Image generated by AI

Disclosure: This article was drafted by me and refined with the help of AI tools.

Every growing Android project eventually spawns a two-headed “God Class.”

On one side, your Application class becomes a dumping ground for global infrastructure—third-party SDKs, crash reporters, and tracking tools. On the other side, your main entry point (typically the MainViewModel) gets choked with UI-blocking startup logic.

It usually looks something like this:

The Anti-Pattern: The Two-Headed God Class

class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
// The framework dumping ground
CrashReportingSDK.getInstance().setCollectionEnabled(true)
HeavyUiSDK.initialize(context = this, ...)
AnalyticsSDK.initialize(this, "API_KEY")
// ... 50 more lines of spaghetti
}
}
class MainViewModel : ViewModel() {
init {
// The UI-blocking dumping ground
updateRemoteConfigs()
checkUserSessionToken()
processPendingDeepLinks()
prefetchHomeFeedData()
// ... UI cannot render until this finishes
}
}

Splitting initialization across these two files creates major problems:

  1. It breaks the Single Responsibility Principle: The app’s entry points are forced to orchestrate the inner workings of every single feature, tightly coupling your modules.
  2. It ignores process lifecycles: Application tasks run indiscriminately on every silent background wakeup, while MainViewModel tasks fail to re-trigger when the app returns to the foreground.
  3. It destroys testability: Hardcoding SDK initializations directly into your entry points makes it incredibly difficult to write isolated unit tests without complicated mocking setups.
  4. It complicates privacy compliance: A centralized dumping ground makes it extremely difficult to dynamically suspend tracking SDKs until user consent under global privacy regulations (such as GDPR, CCPA, and CPRA) is explicitly granted.

To fix this, we need a smarter initialization strategy.

Jetpack App Startup: When to Use It (and When to Avoid It)

Before building a custom solution, we must acknowledge Jetpack App Startup. Google built this library to solve a specific problem: third-party SDKs using their own invisible ContentProviders to auto-initialize, which severely bloats cold start times.

When to use it:

It is the perfect tool for initializing raw framework libraries (like WorkManager or Crashlytics) that do not depend on your app’s internal business logic.

When it becomes dangerous:

Jetpack App Startup runs before Application.onCreate(). This means it executes before your DI graph is constructed. Furthermore, auto-initializing tracking or ad SDKs under the hood can violate global privacy regulations like GDPR, CCPA, and CPRA. If an SDK collects data before your Consent Management Platform (CMP) evaluates user choice, you are non-compliant.

For a robust enterprise app, you need a custom DI-driven pattern for your application-level business logic that respects DI, lifecycles, and user consent.

The Core Concept: Multi-binding and Phases

Instead of the main app module explicitly calling feature setups, we reverse the dependency using a DI concept called Multi-binding.

Whether you use Hilt, Dagger, Koin, or Metro, modern DI frameworks allow individual feature modules to silently contribute implementations to a global Set. The main app module simply injects that collection and runs the tasks, completely unaware of where they came from.

The Phase Contract

We define Priority Phases (in your :core module) and utilize a sealed interface to guarantee developers can only hook into lifecycles we explicitly support.

enum class StartupPhase(val order: Int) {
// The app cannot function if this fails (e.g., Security Configs).
CRITICAL(0),
// Crucial for the first screen (e.g., Feature Flags).
HIGH(1),
// Standard startup tasks (e.g., Pre-warming caches).
NORMAL(2),
// Fire-and-forget, or things that can wait (e.g., Background Observers).
LOW(3)
}

// Sealed to prevent rogue custom lifecycles
sealed interface StartupTask {
val phase: StartupPhase get() = StartupPhase.NORMAL
// Tip: Always profile this timeout on low-end hardware under heavy CPU load!
val timeoutMs: Long get() = 800L
suspend operator fun invoke()
}

interface AppStartupTask : StartupTask
interface ForegroundTask : StartupTask
interface SessionStartupTask : StartupTask


// Thread-safe base class for tasks that SHOULD block and retry safely
abstract class IdempotentStartupTask : StartupTask {
private val mutex = Mutex()
@Volatile private var isCompleted = false

final override suspend operator fun invoke() {
if (isCompleted) return
mutex.withLock {
if (isCompleted) return@withLock
execute()
isCompleted = true
}
}
protected abstract suspend fun execute()
}

Note: Because these tasks execute within the boot sequence, they must be main-safe. Any CPU-intensive work should switch to Dispatchers.Default, while heavy I/O operations (like database reads or network calls) should switch to Dispatchers.IO using withContext (or rely on main-safe libraries like Room and Retrofit).

The Cold Start: Infrastructure, Observers, and Consent

Cold start tasks run once while the application process is alive in memory.

This lifecycle perfectly demonstrates the power of the suspend contract. We can execute standard blocking code or launch continuous observers without stalling the boot sequence (by injecting an application-scoped CoroutineScope). This is incredibly useful for dynamically handling privacy consent.

The Feature Implementations

// A blocking task (delays the next phase until finished)
class SecurityConfigTask @Inject constructor(
private val securityManager: SecurityManager
) : IdempotentStartupTask(), AppStartupTask {
override val phase = StartupPhase.CRITICAL

override suspend fun execute() {
securityManager.initializeEncryptionKeys()
}
}

// A continuous task (fire-and-forget observer)
class PendingSyncObserverTask @Inject constructor(
private val offlineSyncDao: OfflineSyncDao,
private val syncManager: SyncManager,
@ApplicationScope private val appScope: CoroutineScope
) : AppStartupTask {
override val phase = StartupPhase.LOW

override suspend fun invoke() {
appScope.launch {
offlineSyncDao.observePendingActions().collect { pendingItems ->
if (pendingItems.isNotEmpty()) {
syncManager.startUploadProcess(pendingItems)
}
}
}
}
}

// A decentralized, dynamic consent-gated task (GDPR, CCPA, CPRA)
class AnalyticsConsentTask @Inject constructor(
private val consentManager: ConsentManager,
@ApplicationScope private val appScope: CoroutineScope
) : AppStartupTask {
override val phase = StartupPhase.LOW

override suspend fun invoke() {
appScope.launch {
// Continuously observes this specific vendor's consent state.
// Handles cold start, delayed CMP acceptance, AND later revocation in settings!
// Multiple tasks doing this takes virtually zero resources as suspended flows are cheap.
consentManager.observeConsent(Vendor.FIREBASE_ANALYTICS).collect { isGranted ->
Analytics.setCollectionEnabled(isGranted)
}
}
}
}
// Example: How feature modules contribute tasks to the global Set in Hilt/Dagger
@Module
@InstallIn(SingletonComponent::class)
abstract class SecurityModule {
@Binds
@IntoSet
abstract fun bindSecurityConfigTask(task: SecurityConfigTask): AppStartupTask
}

A Unified Task Runner and Error Recovery

To execute these tasks safely across all lifecycles without crashing the app on non-critical failures, we place a single Kotlin extension function in our shared :core module. We use coroutineScope combined with per-task try-catch blocks so non-critical failures are caught and logged, while a critical failure immediately cancels sibling tasks and bubbles up.

class CriticalStartupException(
val failedTaskName: String,
cause: Throwable
) : Exception("Critical task failed: $failedTaskName", cause)

/**
* Groups tasks by phase and runs them concurrently.
*/
suspend fun Iterable<StartupTask>.executeAll() {
val phases = this.groupBy { it.phase.order }.toSortedMap()

for ((_, tasksInPhase) in phases) {
coroutineScope {
tasksInPhase.forEach { task ->
launch {
try {
withTimeout(task.timeoutMs) { task() }
} catch (e: TimeoutCancellationException) {
if (task.phase == StartupPhase.CRITICAL) {
throw CriticalStartupException("${task.javaClass.simpleName} timed out", e)
} else {
// Log non-fatal error
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (task.phase == StartupPhase.CRITICAL) {
throw CriticalStartupException(task.javaClass.simpleName, e)
} else {
// Log non-fatal error
}
}
}
}
}
}
}

The Orchestrator (:app module)

Thanks to our extension function, the orchestrators become incredibly lean.

sealed interface StartupState {
data object Loading : StartupState()
data object Success : StartupState()
data class FatalError(val exception: CriticalStartupException) : StartupState()
}

@Singleton
class AppInitializer @Inject constructor(
private val appTasks: Set<@JvmSuppressWildcards AppStartupTask>,
@ApplicationScope private val scope: CoroutineScope
) {
private val _startupState = MutableStateFlow<StartupState>(StartupState.Loading)
val startupState: StateFlow<StartupState> = _startupState.asStateFlow()

fun initialize() {
// Reset state so the UI shows a loading spinner on retry
_startupState.value = StartupState.Loading

scope.launch {
try {
appTasks.executeAll()
_startupState.value = StartupState.Success
} catch (e: CriticalStartupException) {
_startupState.value = StartupState.FatalError(e)
}
}
}
}

Hooking it up to the UI

To complete the loop, your UI layer simply needs to observe the AppInitializer (usually by injecting it into your global MainViewModel) and react to the state changes.

Using the AndroidX Core Splash Screen API, you can hold the native splash screen on the display until the orchestration finishes:

// Inside your MainActivity
val splashScreen = installSplashScreen()

splashScreen.setKeepOnScreenCondition {
viewModel.startupState.value == StartupState.Loading
}

Once the state moves to Success, your Activity can safely render the main navigation graph. If it emits a FatalError, you render a fallback screen with a “Retry” button that simply calls viewModel.retryInitialization(). Holding the splash screen until StartupState moves out of Loading eliminates UI flicker, race conditions, and abrupt boot crashes.

The Golden Rule of Retries: Enforcing Idempotency

If the user taps “Retry” on a fatal error screen, onRetry simply calls appInitializer.initialize() again. Because this re-runs the entire set, tasks must be idempotent.

Extending IdempotentStartupTask enforces this automatically: if CrashReportingTask succeeded on the first attempt but SecurityConfigTask failed, the retry will safely skip CrashReportingTask via its @Volatile and Mutex state check and immediately proceed to the failing task.

The Warm Start: Foreground Tasks

There is a hidden danger in modern Android development: Background Wakeups. Marketing and attribution tools frequently send silent push notifications just to ping the device and track if the app is still installed.

When these silent uninstall-tracking pings wake your app, the OS creates the process and calls Application.onCreate(). If your cold boot sequence immediately fires off massive API syncs, initializes heavy UI related SDKs, and boots analytics, a simple background ping turns into a massive battery drain. Ironically, this silent battery drain is often what causes users to uninstall the app in the first place!

By moving UI-dependent SDKs or heavy data-sync tasks to ForegroundTask, we hook them into the Android ProcessLifecycleOwner. Because onStart() is not called during a silent background wakeup, these heavy tasks are naturally deferred until the user actually brings the app to the screen.

class RemoteConfigTask @Inject constructor(
private val configManager: ConfigManager
) : IdempotentStartupTask(), ForegroundTask {
override val phase = StartupPhase.HIGH
override val timeoutMs = 5000L // Giving the network a bit more time if needed

override suspend fun execute() {
configManager.updateRemoteConfigs()
}
}
@Singleton
class AppLifecycleObserver @Inject constructor(
private val foregroundTasks: Set<@JvmSuppressWildcards ForegroundTask>,
@ApplicationScope private val scope: CoroutineScope
) : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
scope.launch {
try {
foregroundTasks.executeAll()
} catch (e: CriticalStartupException) {
// Log non-fatal foreground failure and do not crash the app
}
}
}
}

The User Session: Session Startup Tasks

If your app boots up but the user is logged out, attempting to sync a user profile will fail. Session tasks must be attached entirely to your Auth state, bypassing the OS lifecycle completely.

Note: The SessionOrchestrator only handles session related tasks. Clearing local databases and revoking tokens should be strictly delegated to your AuthManager when logout occurs.

@Singleton
class SessionOrchestrator @Inject constructor(
private val authManager: AuthManager,
private val sessionTasks: Set<@JvmSuppressWildcards SessionStartupTask>,
@ApplicationScope private val scope: CoroutineScope
) {
fun startObserving() {
scope.launch {
authManager.authState
.map { it is AuthState.LoggedIn }
.distinctUntilChanged()
.filter { isLoggedIn -> isLoggedIn }
.collect {
try {
sessionTasks.executeAll()
} catch (e: CriticalStartupException) {
// Log failure or route to UI, but keep collector alive
}
}
}
}
}

Orchestrating the Startup Sequence

We delegate the execution in our Application class. Notice how clean and unaware of feature logic this entry point is:

@HiltAndroidApp
class MyApplication : Application() {

@Inject lateinit var appInitializer: AppInitializer
@Inject lateinit var lifecycleObserver: AppLifecycleObserver
@Inject lateinit var sessionOrchestrator: SessionOrchestrator

override fun onCreate() {
super.onCreate()
appInitializer.initialize()
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
sessionOrchestrator.startObserving()
}
}

The UI Layer: Closing the Loop on the MainViewModel

We have successfully cleaned up our entry points by moving global infrastructure to AppStartupTask and global UI SDKs to ForegroundTask.

But what about the screen-specific data fetching (like prefetchHomeFeedData()) that was clogging up our MainViewModel? That logic shouldn’t be part of the global startup sequence at all. Fetching the data for the home feed should happen lazily, exactly when the Home Screen actually renders.

A common anti-pattern is using LaunchedEffect(key) or the newer, highly-optimized SideEffect(key) API to imperatively tell the ViewModel to fetch data. Another is launching coroutines inside a ViewModel’s init block. Both make testing difficult and break the reactive UI pattern.

Instead, use a declarative flow approach. The data loads lazily exactly when the UI begins observing it.

@HiltViewModel
class HomeViewModel @Inject constructor(
private val fetchFeedUseCase: FetchFeedUseCase
) : ViewModel() {

// No init blocks. No LaunchedEffect. No SideEffect.
// Data fetching starts automatically when the UI subscribes.
val uiState: StateFlow<HomeState> = flow {
// Wrap a one-shot suspend call into a cold flow
emit(fetchFeedUseCase())
}.map {
// Map result to UI state (e.g. Success, Error) here
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = HomeState.Loading
)
}
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
// Lifecycle-aware collection stops wasting resources when the app is backgrounded
val state by viewModel.uiState.collectAsStateWithLifecycle()

// Render UI...
}

Why This Architecture Wins in Production

Beyond cleaning up your entry points, this architecture solves several massive enterprise challenges:

  1. Decentralized Privacy Compliance: Because tasks can inject continuous observers, privacy-sensitive SDKs can react dynamically to the ConsentManager. They initialize when their specific vendor consent is granted, and shut down gracefully if revoked later in settings, keeping your :core:consent module completely decoupled from third-party SDK dependencies.
  2. Preventing Battery Drain from Silent Wakeups: By shifting UI-bound SDKs and heavy syncs to ForegroundTask, you eliminate silent resource usage caused by background push pings (like uninstall tracking) while the device is in the user’s pocket.
  3. A Tester’s Dream: You can unit test orchestrators by passing mocked task sets and test individual initialization tasks in complete isolation. Furthermore, because ViewModels no longer use init blocks or Compose side-effects for startup data fetching, you can instantiate them in unit tests without firing off unexpected network calls.


The Android Startup Pattern: A Lifecycle-Aware, Multi-Module Approach was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.