In undisciplined Android development, beginners frequently write all application code directly inside a single Activity or Fragment. This creates the infamous "God Activity"βa monolithic 3,000-line class that simultaneously handles:
Consequences:
android.content.Context cannot run in lightning-fast JVM local unit tests (JUnit 5); it requires slow on-device emulator testing.The most important software design principle in Android is Separation of Concerns: An Activity or Composable should strictly be responsible for displaying visual pixels and capturing user touch events. All business logic, caching policies, network orchestration, and state calculations must reside in isolated, dedicated architectural layers.
Googleβs official architecture guideline structures every modern Android app into three distinct layers:
ViewModel) that hold and transform data into observable UI State.ValidateEnrollmentEligibilityUseCase, CalculateTuitionDiscountUseCase). Sits between UI and Data layers.CourseRepository) that coordinate between Data Sources: Local Database (Room/DataStore) and Remote Network (Retrofit/Ktor).In the MVVM architectural pattern, data and events travel in a strict, single-direction cycle:
ViewModel holds an observable stream of immutable UI state (StateFlow<UiState>). The UI layer strictly observes and renders this state.UI state should represent a complete, deterministic snapshot of the screen at any instant in time. Using a Sealed Interface guarantees compile-time exhaustiveness:
package com.devascent.architecture.state
// Immutable Domain Entity
data class Course(
val id: String,
val title: String,
val unitCount: Int,
val isEnrolled: Boolean
)
// Sealed UI State: Represents all mutually exclusive states of the screen
sealed interface CourseUiState {
object Loading : CourseUiState
data class Success(val courses: List<Course>, val isRefreshing: Boolean = false) : CourseUiState
data class Error(val message: String) : CourseUiState
}
The Repository Pattern mediates between domain/UI layers and multiple data sources. In enterprise mobile development, apps follow an Offline-First Caching Strategy:
Flow<List<Course>> querying the Local SQLite/Room Database as the definitive SSOT.
In traditional Android programming, registering listeners (e.g. GPS location trackers, network monitors) required manually writing setup and teardown code inside onStart() and onStop(). Forgetting to unregister caused memory leaks and IllegalStateException crashes when updating destroyed views.
Lifecycle-Aware Components (implementing DefaultLifecycleObserver) automatically monitor the lifecycle state of their host:
LifecycleOwner: Activities and Fragments implement this interface, exposing an observable Lifecycle object.repeatOnLifecycle(Lifecycle.State.STARTED): Suspends the calling coroutine until the lifecycle reaches at least the STARTED state, and cancels execution when it falls below STARTED (e.g. app sent to background).collectAsStateWithLifecycle(): The Compose standard for flow collection, built directly on top of repeatOnLifecycle.
Classes should not instantiate their own dependencies. For example, a ViewModel should never write:
val repository = CourseRepositoryImpl(NetworkClient(), Database.getInstance())
This tightly couples the ViewModel to concrete implementations, making unit testing impossible.
With Constructor Dependency Injection, the ViewModel declares its dependencies as interface parameters in its constructor:
class CourseViewModel(private val repository: CourseRepository) : ViewModel()
This allows tests to inject a FakeCourseRepository instantaneously on a desktop JVM without launching a real network server or database. In modern enterprise Android, Hilt (built on Dagger) or lightweight AppContainer manual DI provides automated dependency graphs.
The complete implementation below showcases the entire clean architectural stack: an abstract CourseRepository interface, an offline-first caching repository, an architectural ViewModel exposing a StateFlow<CourseUiState>, an AppContainer dependency injection provider, and a reactive Compose UI screen.
package com.devascent.architecture.app
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
// ==================== 1. DOMAIN & DATA CONTRACTS ====================
data class CourseItem(val id: String, val title: String, val isEnrolled: Boolean)
interface CourseRepository {
fun getCoursesStream(): Flow<List<CourseItem>>
suspend fun refreshCourses()
suspend fun toggleEnrollment(courseId: String)
}
// ==================== 2. DATA LAYER IMPLEMENTATION ====================
class OfflineFirstCourseRepository : CourseRepository {
// Simulating local database table (Room) as the SSOT
private val _localDb = MutableStateFlow(
listOf(
CourseItem("c1", "Android App Development", true),
CourseItem("c2", "Enterprise Java Engineering", true),
CourseItem("c3", "Full-Stack Web Development", false)
)
)
override fun getCoursesStream(): Flow<List<CourseItem>> = _localDb.asStateFlow()
override suspend fun refreshCourses() {
delay(800) // Simulate remote network fetch latency
Log.i("ArchitectureApp", "Repository: Remote network sync completed -> Updated local cache")
}
override suspend fun toggleEnrollment(courseId: String) {
_localDb.update { list ->
list.map { course ->
if (course.id == courseId) course.copy(isEnrolled = !course.isEnrolled) else course
}
}
}
}
// ==================== 3. DEPENDENCY INJECTION CONTAINER ====================
class AppContainer {
val courseRepository: CourseRepository by lazy { OfflineFirstCourseRepository() }
}
// ==================== 4. UI LAYER: VIEWMODEL ====================
sealed interface CatalogUiState {
object Loading : CatalogUiState
data class Success(val courses: List<CourseItem>) : CatalogUiState
data class Error(val message: String) : CatalogUiState
}
class CourseCatalogViewModel(
private val repository: CourseRepository
) : ViewModel() {
val uiState: StateFlow<CatalogUiState> = repository.getCoursesStream()
.map<List<CourseItem>, CatalogUiState> { CatalogUiState.Success(it) }
.onStart { emit(CatalogUiState.Loading) }
.catch { emit(CatalogUiState.Error(it.localizedMessage ?: "Unexpected error")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = CatalogUiState.Loading
)
fun onToggleEnrollment(courseId: String) {
viewModelScope.launch {
repository.toggleEnrollment(courseId)
Log.d("ArchitectureApp", "ViewModel: Toggled enrollment for course $courseId")
}
}
}
// ViewModel Factory for Manual DI
class CatalogViewModelFactory(private val repo: CourseRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
CourseCatalogViewModel(repo) as T
}
// ==================== 5. UI LAYER: COMPOSABLE SCREEN ====================
class ArchitectureActivity : ComponentActivity() {
private val appContainer = AppContainer()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
val vm: CourseCatalogViewModel = viewModel(
factory = CatalogViewModelFactory(appContainer.courseRepository)
)
CourseCatalogScreen(viewModel = vm)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CourseCatalogScreen(viewModel: CourseCatalogViewModel) {
// Lifecycle-aware state collection
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopAppBar(title = { Text("DevAscent Course Architecture") }) }
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
when (val s = state) {
is CatalogUiState.Loading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
is CatalogUiState.Error -> {
Text(
text = "Error: ${s.message}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.Center)
)
}
is CatalogUiState.Success -> {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(s.courses, key = { it.id }) { course ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = course.title, style = MaterialTheme.typography.titleMedium)
FilledTonalButton(
onClick = { viewModel.onToggleEnrollment(course.id) }
) {
Text(if (course.isEnrolled) "Enrolled" else "Enroll")
}
}
}
}
}
}
}
}
}
}
StateFlow<UiState>).onClick). The UI never mutates state directly; it requests mutations that the ViewModel processes.Flow. When remote network data is fetched, it is written into the local database first; Room then automatically notifies the Flow, ensuring the app works 100% offline and never displays out-of-sync data across screens.
isLoading: StateFlow<Boolean>, data: StateFlow<List?>, error: StateFlow<String?>) can lead to illegal visual states (such as displaying both a loading spinner and an error message simultaneously). A Sealed Interface (e.g. Loading, Success, Error) models all screen states as mutually exclusive, complete snapshots, enabling compile-time exhaustiveness checking in Kotlin when expressions.
SharingStarted.WhileSubscribed(5000) in stateIn()?SharingStarted.WhileSubscribed(5000) ensures the upstream cold flow is kept active only as long as there is at least one active UI collector. The 5000ms delay window accommodates Android configuration changes: when an Activity rotates, the collector momentarily detaches for ~200ms and reattaches. The 5-second buffer keeps the flow active through the rotation, preventing unnecessary cancellations and redundant database/network refetches.
FakeRepository directly into the ViewModel constructor, running complete test suites in milliseconds on the JVM without needing Android mocks or device hardware.
collectAsStateWithLifecycle() improve performance over standard collectAsState()?collectAsState() continues actively collecting flow emissions as long as the Composable remains in the composition tree, even if the parent Activity is in the background (Lifecycle.State.STOPPED). This wastes CPU, battery, and network data. collectAsStateWithLifecycle() automatically suspends flow collection when the app drops below STARTED and restarts collection seamlessly when the user returns to the foreground.