MVVM Architecture

Android Development โ€” Architecture & Data
Course 2 ยท Chapter 8 ยท MVVM Architecture

๐Ÿ—๏ธ MVVM Architecture

Every chapter since Chapter 2 has quietly been building toward the same architecture without naming it: MVVM. This final chapter names it explicitly, formalizes the repository pattern that's appeared in every data chapter, and shows why a ViewModel talking only to a repository interface โ€” never directly to Room or Retrofit โ€” is a deliberate design choice, not an accident.

๐ŸŽญ MVVM, Named

Model

The data layer โ€” Room entities and DAOs (Chapter 3), Retrofit API services (Chapter 4), DataStore (Chapter 7) โ€” all unified behind repositories.

View

Compose composables (Chapter 1) โ€” purely a function of whatever UiState they're given, with no direct knowledge of where that state came from.

ViewModel

Owns and exposes UiState as StateFlow (Chapter 2), reacting to user actions by calling into the Model layer โ€” never touching a database or network call directly.

Nothing about this is new material โ€” it's a name for the shape every chapter's code examples already had. Naming it matters because it turns "how should I structure this new screen?" into a question with a known, repeatable answer.

๐Ÿ—ƒ๏ธ The Repository Pattern, Properly

Earlier chapters' repositories each wrapped a single data source. A repository can just as easily combine several, presenting one unified API โ€” this is the classic offline-first shape: check local data immediately, refresh from the network in the background, and let the local database remain the single source of truth the UI actually observes:

class TaskRepository @Inject constructor( private val dao: TaskDao, private val api: TaskApiService ) { // The UI only ever observes the LOCAL database โ€” never the network directly val tasks: Flow<List<Task>> = dao.getAllTasks() suspend fun refresh() { try { val remoteTasks = api.getTasks() remoteTasks.forEach { dao.insert(it) } // writing to Room triggers tasks (the Flow) to re-emit } catch (e: IOException) { // Network failed โ€” the UI keeps showing whatever's already cached locally } } }

The UI never directly asks "give me the network version" or "give me the cached version" โ€” it just observes tasks, and the repository decides how that data gets kept fresh. This is exactly why refresh()'s catch block can simply do nothing on failure: the already-displayed cached data from Room is a perfectly reasonable fallback, not an error state the UI needs to handle specially.

๐Ÿšซ Why a ViewModel Shouldn't Know About Room or Retrofit Directly

Every ViewModel across this course took a repository as a constructor dependency, never a TaskDao or ApiService directly. This is dependency inversion โ€” the ViewModel depends on an abstraction (what the repository exposes), not the concrete implementation behind it:

// A repository INTERFACE โ€” the ViewModel only ever sees this interface TaskRepository { val tasks: Flow<List<Task>> suspend fun refresh() } // The real implementation โ€” Room + Retrofit specifics live ONLY here class TaskRepositoryImpl @Inject constructor( private val dao: TaskDao, private val api: TaskApiService ) : TaskRepository { override val tasks = dao.getAllTasks() override suspend fun refresh() { /* ... */ } }
// A Hilt module tells the framework which implementation to provide for the interface @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Binds abstract fun bindTaskRepository(impl: TaskRepositoryImpl): TaskRepository }

@Binds (a lighter alternative to @Provides, used specifically for "this interface, backed by this implementation" mappings) tells Hilt: whenever something asks for a TaskRepository, hand it a TaskRepositoryImpl. A ViewModel constructor asking for TaskRepository never knows or cares that Room and Retrofit exist underneath โ€” swapping in a fake implementation for a test only requires changing this one binding, with the ViewModel's own code completely untouched.

โš  Skipping the Interface Is Fine for a Small App

Every earlier chapter in this course used a concrete repository class directly (no interface) โ€” that's a genuinely reasonable simplification for a small app or a learning example. The interface-plus-@Binds version shown here is what a larger, more seriously tested codebase adds once the flexibility (swappable implementations, easier fakes for tests) actually earns its extra ceremony โ€” not a requirement for every app, every time.

๐Ÿ“š A Note on "Clean Architecture" Layers

Some codebases add a further domain layer between the UI and data layers โ€” standalone "use case" classes (e.g. GetTasksUseCase, RefreshTasksUseCase) that a ViewModel calls instead of a repository directly, intended to hold business logic that doesn't belong in either the UI or the data layer. This course's examples skip that layer deliberately โ€” for the scale of app built throughout these chapters, ViewModel-calls-repository-directly is a completely legitimate, widely-used architecture. A domain layer is worth reaching for once business logic (not just data fetching) grows complex enough to want its own dedicated, independently-testable home.

MVVM vs Frontend Architecture

React (hooks + API layer)Android MVVM
UI layerFunction componentsComposables
State + logic ownerA custom hook, or a storeViewModel
Data access abstractionAn API client module / service layerRepository (interface + implementation)
Swapping real for fake dataMock the API moduleSwap the @Binds implementation

๐Ÿ’ป Coding Challenges

Challenge 1: An Offline-First Repository

Write a NoteRepository combining Course 2's Note entity/NoteDao with a hypothetical NoteApiService, exposing notes as a Flow from the DAO and a suspend refresh() that fetches from the API and writes results into the DAO, with a try/catch that silently ignores network failure (falling back to cached data).

Goal: Practice the offline-first repository pattern combining two data sources.

โ†’ Solution

Challenge 2: Interface + @Binds

Extract Challenge 1's NoteRepository into an interface NoteRepository plus an implementation NoteRepositoryImpl, and write a Hilt @Module with an abstract @Binds function mapping the interface to the implementation.

Goal: Practice the dependency-inversion setup, distinct from a plain @Inject-constructor class.

โ†’ Solution

Challenge 3: The Full Stack, One More Time

Write a NoteViewModel depending only on the NoteRepository interface (not the impl), exposing a NotesUiState (notes, isLoading, errorMessage), calling refresh() on init, and a NotesScreen composable collecting and rendering that state โ€” pulling together everything from Chapters 1 through 8 into one complete, working screen.

Goal: Assemble one final, complete example using every architectural piece from this course.

โ†’ Solution

๐Ÿ’ก Course 2 Complete โ€” What This Sets Up

Architecture & Data closes here having covered Compose, ViewModel/StateFlow, Room, Retrofit, Hilt, coroutine scopes, DataStore, and now the MVVM pattern tying them together. Course 3 (Production & Publishing) shifts focus to shipping this kind of app for real โ€” testing it, handling background work, securing it, optimizing performance, and getting it into the Play Store.

๐ŸŽฏ What's Next

Android Development โ€” Architecture & Data (Course 2) is complete. Course 3 (Production & Publishing) begins with Jetpack Compose Advanced โ€” custom layouts, animations, theming, and performance.