DEV Community

myougaTheAxo
myougaTheAxo

Posted on

How AI Structures Android Apps: MVVM + Room + Compose Architecture Explained

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)            │
└─────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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>
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • @Entity defines your table structure with automatic migration support
  • suspend functions = 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)
        }
}
Enter fullscreen mode Exit fullscreen mode

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
                )
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Patterns:

  • MutableStateFlow holds mutable state; expose via asStateFlow() to prevent external mutations
  • update { } = atomic state modifications (thread-safe)
  • viewModelScope = automatic cleanup when ViewModel is destroyed
  • All async operations use launch { } with StateFlow updates

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)
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Data Flow Summary

  1. User clicks button → Composable calls viewModel.updateTask()
  2. ViewModel launches coroutine → Updates _uiState to isLoading = true
  3. Repository executestaskDao.updateTask() writes to SQLite
  4. Room DAO emitsgetAllTasks() Flow detects DB change
  5. Repository propagates → Updated tasks flow to ViewModel
  6. ViewModel updates state_uiState.update { it.copy(tasks = newTasks) }
  7. Composable recomposescollectAsState() triggers recomposition with new data
  8. 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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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)