How AI Structures Android Apps: MVVM + Room + Compose Architecture Explained
Building scalable Android applications requires a solid architectural foundation. When developing AI-powered Android apps (like the 8 templates in our Gumroad collection), we follow the MVVM (Model-View-ViewModel) pattern combined with Jetpack Compose for UI, Room for local persistence, and modern reactive programming. This article breaks down the complete architecture stack, from UI components to database operations.
The Architecture Stack Overview
The modern Android architecture follows a layered approach:
┌─────────────────────────────────────────────┐
│ Composable UI Layer │
│ (Buttons, Lists, Forms - @Composable) │
└──────────────────┬──────────────────────────┘
│ (StateFlow observations)
┌──────────────────▼──────────────────────────┐
│ ViewModel Layer │
│ (State management, business logic) │
└──────────────────┬──────────────────────────┘
│ (suspend functions)
┌──────────────────▼──────────────────────────┐
│ Repository Layer │
│ (Data abstraction, combines local + remote) │
└──────────────────┬──────────────────────────┘
│
┌──────────┴──────────┐
│ │
┌───────▼────────┐ ┌──────▼──────────┐
│ Room DAO │ │ Remote API │
│ (Local DB) │ │ (Network) │
└───────┬────────┘ └─────────────────┘
│
┌───────▼────────────────────────────────────┐
│ SQLite Database │
│ (@Entity models, relationships) │
└─────────────────────────────────────────────┘
Layer 1: SQLite Database + Room DAO
The foundation is your data model. Room abstracts SQLite completely, providing type-safe database access through Kotlin coroutines.
Entity Definition:
@Entity(tableName = "tasks")
data class TaskEntity(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val title: String,
val description: String,
val isCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val dueDate: Long? = null
)
@Entity(tableName = "categories")
data class CategoryEntity(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val name: String
)
// One-to-many relationship
data class CategoryWithTasks(
@Embedded val category: CategoryEntity,
@Relation(
parentColumn = "id",
entityColumn = "categoryId"
)
val tasks: List<TaskEntity>
)
DAO (Data Access Object):
@Dao
interface TaskDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: TaskEntity)
@Update
suspend fun updateTask(task: TaskEntity)
@Delete
suspend fun deleteTask(task: TaskEntity)
@Query("SELECT * FROM tasks WHERE id = :taskId")
suspend fun getTaskById(taskId: Int): TaskEntity?
@Query("SELECT * FROM tasks ORDER BY dueDate ASC")
fun getAllTasks(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE isCompleted = 0 ORDER BY dueDate ASC")
fun getActiveTasks(): Flow<List<TaskEntity>>
@Query("DELETE FROM tasks WHERE id = :taskId")
suspend fun deleteTaskById(taskId: Int)
}
Key Points:
-
@Entitydefines your table structure with automatic migration support -
suspendfunctions = coroutine-friendly, non-blocking database calls -
Flow<T>= reactive streams that emit updated data whenever the DB changes - DAOs should only handle raw database operations—no business logic here
Layer 2: Repository Pattern
The Repository abstracts data sources (local DB, remote API, cache) behind a single interface. This enables clean separation and testability.
class TaskRepository(
private val taskDao: TaskDao,
private val taskApi: TaskApi // For remote sync
) {
// Single source of truth: always read from Room first
val allTasks: Flow<List<TaskEntity>> = taskDao.getAllTasks()
.map { tasks ->
// Transform DB entities to UI models
tasks.map { it.toDomainModel() }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyList()
)
suspend fun createTask(title: String, description: String): Result<TaskEntity> =
try {
val entity = TaskEntity(title = title, description = description)
taskDao.insertTask(entity)
Result.success(entity)
} catch (e: Exception) {
Result.failure(e)
}
suspend fun updateTask(id: Int, title: String, isCompleted: Boolean): Result<Unit> =
try {
val existing = taskDao.getTaskById(id) ?: return Result.failure(Exception("Not found"))
taskDao.updateTask(existing.copy(title = title, isCompleted = isCompleted))
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
suspend fun deleteTask(id: Int): Result<Unit> =
try {
taskDao.deleteTaskById(id)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
// Sync with remote (background operation)
suspend fun syncTasks(): Result<Unit> =
try {
val remoteTasks = taskApi.fetchAllTasks()
remoteTasks.forEach { remoteTask ->
taskDao.insertTask(remoteTask.toEntity())
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
Repository Responsibilities:
- Abstract multiple data sources (Room, API, preferences)
- Implement business rules (retry logic, conflict resolution)
- Return
Flow<T>for reactive UI updates - Use
Result<T>for error handling at the UI layer
Layer 3: ViewModel + StateFlow
ViewModels hold reactive UI state and expose it via StateFlow. They survive configuration changes (like device rotation) and clean up resources automatically.
data class TaskUiState(
val tasks: List<TaskEntity> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
val selectedTaskId: Int? = null
)
@HiltViewModel
class TaskViewModel @Inject constructor(
private val repository: TaskRepository
) : ViewModel() {
// UI State: single source of truth
private val _uiState = MutableStateFlow(TaskUiState())
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
init {
// Collect repository tasks into UI state
viewModelScope.launch {
repository.allTasks.collect { tasks ->
_uiState.update { it.copy(tasks = tasks) }
}
}
}
fun createTask(title: String, description: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = repository.createTask(title, description)
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.exceptionOrNull()?.message
)
}
}
}
fun updateTask(id: Int, title: String, isCompleted: Boolean) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = repository.updateTask(id, title, isCompleted)
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.exceptionOrNull()?.message
)
}
}
}
fun deleteTask(id: Int) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = repository.deleteTask(id)
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.exceptionOrNull()?.message
)
}
}
}
fun selectTask(taskId: Int) {
_uiState.update { it.copy(selectedTaskId = taskId) }
}
fun syncTasks() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = repository.syncTasks()
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.exceptionOrNull()?.message
)
}
}
}
}
Key Patterns:
-
MutableStateFlowholds mutable state; expose viaasStateFlow()to prevent external mutations -
update { }= atomic state modifications (thread-safe) -
viewModelScope= automatic cleanup when ViewModel is destroyed - All async operations use
launch { }withStateFlowupdates
Layer 4: Jetpack Compose UI
Composables observe StateFlow and recompose automatically when state changes.
@Composable
fun TaskScreen(
viewModel: TaskViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Loading indicator
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
}
// Error message
uiState.errorMessage?.let { error ->
Text(
text = "Error: $error",
color = Color.Red,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
}
// Task creation form
TaskCreationForm { title, description ->
viewModel.createTask(title, description)
}
Spacer(modifier = Modifier.height(16.dp))
// Task list (reactive - recomposes when uiState.tasks changes)
LazyColumn {
items(uiState.tasks, key = { it.id }) { task ->
TaskItem(
task = task,
isSelected = uiState.selectedTaskId == task.id,
onTaskClick = { viewModel.selectTask(task.id) },
onTaskUpdate = { updatedTask ->
viewModel.updateTask(
updatedTask.id,
updatedTask.title,
updatedTask.isCompleted
)
},
onTaskDelete = { viewModel.deleteTask(task.id) }
)
}
}
// Sync button
Button(
onClick = { viewModel.syncTasks() },
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp)
) {
Text("Sync with Server")
}
}
}
@Composable
fun TaskItem(
task: TaskEntity,
isSelected: Boolean,
onTaskClick: () -> Unit,
onTaskUpdate: (TaskEntity) -> Unit,
onTaskDelete: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { onTaskClick() }
.background(if (isSelected) Color.LightGray else Color.White)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = task.isCompleted,
onCheckedChange = { isCompleted ->
onTaskUpdate(task.copy(isCompleted = isCompleted))
}
)
Text(
text = task.title,
modifier = Modifier
.weight(1f)
.padding(start = 8.dp),
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else TextDecoration.None
)
IconButton(onClick = onTaskDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete task"
)
}
}
Text(
text = task.description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
Data Flow Summary
-
User clicks button → Composable calls
viewModel.updateTask() -
ViewModel launches coroutine → Updates
_uiStatetoisLoading = true -
Repository executes →
taskDao.updateTask()writes to SQLite -
Room DAO emits →
getAllTasks()Flow detects DB change - Repository propagates → Updated tasks flow to ViewModel
-
ViewModel updates state →
_uiState.update { it.copy(tasks = newTasks) } -
Composable recomposes →
collectAsState()triggers recomposition with new data - UI renders instantly → New task list appears on screen
Single Source of Truth Principle
- Database is source of truth: All data lives in SQLite; UI reads from there
- No duplicate state: Don't cache the task list in both ViewModel and Repository
- Reactive updates: Any component can trigger a DB change; all observers see it immediately
- Predictable UI: Same data always produces same UI (deterministic rendering)
Testing the Architecture
@RunWith(AndroidTestRunner::class)
class TaskRepositoryTest {
private lateinit var taskDao: TaskDao
private lateinit var taskDb: TaskDatabase
@Before
fun setup() {
taskDb = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
TaskDatabase::class.java
).build()
taskDao = taskDb.taskDao()
}
@Test
fun insertAndRetrieveTask() = runTest {
val task = TaskEntity(title = "Test Task", description = "Test")
taskDao.insertTask(task)
val retrieved = taskDao.getTaskById(1)
assertNotNull(retrieved)
assertEquals("Test Task", retrieved.title)
}
@Test
fun tasksFlowEmitsOnUpdate() = runTest {
val tasks = mutableListOf<TaskEntity>()
val collectJob = launch {
taskDao.getAllTasks().collect { tasks.addAll(it) }
}
taskDao.insertTask(TaskEntity(title = "Task 1", description = ""))
taskDao.insertTask(TaskEntity(title = "Task 2", description = ""))
advanceUntilIdle()
assertEquals(2, tasks.size)
collectJob.cancel()
}
}
Why This Architecture?
✓ Testable: Easy to mock Repository/DAO for unit tests
✓ Maintainable: Each layer has a single responsibility
✓ Scalable: Add caching, offline-first sync without changing UI code
✓ Reactive: StateFlow ensures UI always reflects latest data
✓ Coroutine-friendly: Suspend functions integrate naturally with Kotlin's async model
Conclusion
This MVVM + Room + Compose architecture is the industry standard for scalable Android development. Whether you're building a task manager, note app, or AI-powered tool, this pattern scales from 100 to 10,000+ items without performance degradation.
All 8 templates in our Gumroad collection follow this exact architecture—from budget managers to habit trackers. Study these real-world implementations to master the pattern.
Ready to build production-grade Android apps?
All 8 templates follow this exact architecture. https://myougatheax.gumroad.com
Top comments (0)