πŸ€– Unit 9 β€’ Enterprise Android Architecture

Android Architecture: MVVM, State & Clean Layering

The authoritative architectural masterclass on enterprise Android software engineering: Eliminating the "God Activity" anti-pattern via Separation of Concerns (SoC), Google's official Modern Android Architecture (UI Layer, Domain Layer, Data Layer), the Model-View-ViewModel (MVVM) pattern, Unidirectional Data Flow (UDF), immutable sealed UI state modeling, the Repository Pattern with offline-first synchronization, lifecycle-aware coroutine collection, and Dependency Injection foundations.

Curriculum: Mobile Software Engineering (Android Track)
Unit: 09 of 15
Prerequisite: Unit 05 (Activities), Unit 06 (State), Unit 07 (Navigation)
Standard: Google Modern App Architecture (MAD) Guide
01

The Need for App Architecture: The "God Activity" Anti-Pattern

⚠️ The Catastrophe of Monolithic Component Design

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:

  • Parsing binary XML layout files and binding view IDs.
  • Spawning HTTP network requests via background threads.
  • Directly opening SQLite database cursors and executing raw SQL transactions.
  • Validating user business logic, transforming domain data, and formatting date strings.
  • Handling Android OS lifecycle callbacks and configuration change teardowns.

Consequences:

  1. Fragility During Lifecycle Events: Rotating the screen destroys the Activity, immediately aborting in-flight network requests and leaking thread memory.
  2. Zero Testability: Code coupled directly to android.content.Context cannot run in lightning-fast JVM local unit tests (JUnit 5); it requires slow on-device emulator testing.
  3. Team Gridlock: Multiple engineers editing the same monolithic file experience continuous git merge conflicts.
πŸ›‘οΈ Separation of Concerns (SoC) Principle

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.

02

Google's Modern 3-Tier Layered Architecture

πŸ›οΈ The Three Canonical Layers

Google’s official architecture guideline structures every modern Android app into three distinct layers:

  • 1. UI Layer (Presentation): Renders visual elements and manages UI state. Consists of UI Elements (Composables or XML Views) and State Holders (ViewModel) that hold and transform data into observable UI State.
  • 2. Domain Layer (Optional): Encapsulates complex, reusable business logic into single-responsibility Use Cases (e.g., ValidateEnrollmentEligibilityUseCase, CalculateTuitionDiscountUseCase). Sits between UI and Data layers.
  • 3. Data Layer: The Single Source of Truth (SSOT) containing business data and business rules. Built as Repositories (e.g., CourseRepository) that coordinate between Data Sources: Local Database (Room/DataStore) and Remote Network (Retrofit/Ktor).
Flowchart 1: Google's 3-Tier Layered Architecture & Data Pipeline
UI Layer: Screen Composable Observes StateFlow & Emits Events
↔
UI Layer: Jetpack ViewModel Survives Rotation / State Reduction
↓ Invocations & Flow Observables
Domain Layer: Use Case (Optional) Pure Kotlin Business Logic
↓ Repository Protocol Interface
Data Layer: CourseRepository Mediates Cache & Remote API
↓ Dual Data Sources
Local Data Source (Room DB) Single Source of Truth (Disk)
↔
Remote Data Source (Retrofit API) Cloud Backend REST / JSON
03

Model-View-ViewModel (MVVM) & Unidirectional Data Flow

πŸ”„ Unidirectional Data Flow (UDF) Loop

In the MVVM architectural pattern, data and events travel in a strict, single-direction cycle:

  • State Flows DOWN: The ViewModel holds an observable stream of immutable UI state (StateFlow<UiState>). The UI layer strictly observes and renders this state.
  • Events Flow UP: When a user interacts with the screen (taps a button, enters text), the UI passes that interaction as an Event to the ViewModel via a function call. The UI never mutates state directly!
Flowchart 2: Unidirectional Data Flow (UDF) State Machine Cycle
UI Layer (Compose) Dumb View Component
→
Event (Flows UP) onEnrollClicked(courseId)
→
ViewModel Processes Business Rule
↓
Targeted Recomposition Skips unchanged nodes
←
State (Flows DOWN) UiState.Success(data)
←
State Reduction _uiState.update { ... }
πŸ“¦ Modeling UI State with Sealed Interfaces

UI state should represent a complete, deterministic snapshot of the screen at any instant in time. Using a Sealed Interface guarantees compile-time exhaustiveness:

CourseUiState.kt β€” Sealed State Architecture
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
}
04

The Repository Pattern: Offline-First Synchronization

πŸ—„οΈ Single Source of Truth (SSOT) Architecture

The Repository Pattern mediates between domain/UI layers and multiple data sources. In enterprise mobile development, apps follow an Offline-First Caching Strategy:

  • The UI layer never queries the remote network API directly.
  • The repository exposes a continuous reactive Flow<List<Course>> querying the Local SQLite/Room Database as the definitive SSOT.
  • When refreshed, the repository launches an asynchronous background network request, saves the remote data into the local database, and Room automatically notifies the Flow, triggering instant, seamless UI updates.
Flowchart 3: Offline-First Repository Synchronization Pipeline
UI Observes Flow repository.getCourses()
←
Room Database (SSOT) Emits Cached Data Instantly
←
Local Disk Storage Immediate 0ms App Launch
↓ Background Coroutine Triggers Sync
Retrofit Cloud API GET /api/v1/courses
→
Network Response Received Deserializes JSON Payload
→
Room Upsert Transaction Inserts / Updates Local DB
05

Jetpack Lifecycle-Aware Components

πŸ›‘οΈ Eliminating Memory Leaks & Crashes

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.
06

Dependency Injection (DI) Foundations

πŸ’‰ Inversion of Control (IoC)

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.

07

Production Capstone: DevAscent Clean Architecture Architecture Suite

πŸš€ Complete Layered Implementation with DI, ViewModel & UDF

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.

CleanArchitectureApp.kt β€” Complete Enterprise Architecture Suite
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")
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
Runtime Logcat & Clean Architecture Layer Execution Stream
I/ArchitectureApp: AppContainer initialized: OfflineFirstCourseRepository registered via Constructor DI I/ArchitectureApp: CourseCatalogViewModel created via CatalogViewModelFactory (Survives Configuration Changes) D/ArchitectureApp: Flow stateIn: CatalogUiState.Loading emitted to UI D/ArchitectureApp: OfflineFirstCourseRepository: Local DB SSOT emitted 3 cached records I/ArchitectureApp: CourseCatalogScreen recomposed: CatalogUiState.Success(3 courses) rendered D/ArchitectureApp: User clicked 'Enroll' on 'Full-Stack Web Development' (c3) D/ArchitectureApp: Event flows UP -> ViewModel.onToggleEnrollment(c3) -> launches coroutine in viewModelScope I/ArchitectureApp: OfflineFirstCourseRepository: Updated local database cache record c3 -> isEnrolled = true I/ArchitectureApp: Room Flow automatically emitted updated dataset -> CatalogUiState.Success I/ArchitectureApp: Targeted recomposition executed strictly on Item c3 Card with 0 dropped frames
08

University Exam & Technical Interview Viva Preparation Guide

Q1. What is the "God Activity" anti-pattern and how does Modern Android Architecture resolve it?
The "God Activity" is an architectural anti-pattern where an Activity handles UI rendering, network requests, database transactions, input validation, and business logic simultaneously in a single file. This causes catastrophic fragility during configuration changes, memory leaks, and untestable code. Modern Android Architecture resolves this via the Separation of Concerns (SoC) principle, partitioning the application into a UI Layer (Presentation), an optional Domain Layer (Business Rules), and a Data Layer (Repository & Sources).
Q2. What are the three core layers in Google's official Modern App Architecture guide?
The three canonical layers are:
  1. UI Layer: Displays application data on screen and acts as the entry point for user interaction. Contains visual Composables/Views and ViewModel state holders.
  2. Domain Layer (Optional): Encapsulates complex, reusable business logic into single-responsibility Use Cases / Interactors, simplifying ViewModels.
  3. Data Layer: Contains application data and business rules. Mediates between local database storage (Room) and remote network endpoints (Retrofit) via Repositories.
Q3. What is Unidirectional Data Flow (UDF) in the MVVM pattern?
Unidirectional Data Flow (UDF) is an architectural design pattern where data moves in only one direction:
  • State flows DOWN: From the ViewModel to the UI as an observable, immutable stream (StateFlow<UiState>).
  • Events flow UP: From the UI to the ViewModel as function calls triggered by user interaction (e.g. onClick). The UI never mutates state directly; it requests mutations that the ViewModel processes.
Q4. Why is the Repository Pattern considered the Single Source of Truth (SSOT)?
The Repository Pattern abstracts data sources behind a clean interface. In an Offline-First architecture, the local database (Room) is the single source of truth: the UI continuously observes a reactive database 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.
Q5. Why should UI state be modeled as a sealed interface rather than multiple independent StateFlows?
Using multiple independent state flows (e.g. 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.
Q6. What is the function of 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.
Q7. What is Dependency Injection (DI) and how does Constructor Injection aid unit testing?
Dependency Injection is an implementation of Inversion of Control (IoC) where an object receives its dependencies from an external provider rather than instantiating them itself. Constructor Injection passes dependencies as interface parameters in class constructors. In unit testing, developers can pass a lightweight FakeRepository directly into the ViewModel constructor, running complete test suites in milliseconds on the JVM without needing Android mocks or device hardware.
Q8. How does collectAsStateWithLifecycle() improve performance over standard collectAsState()?
Standard 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.