MVVM Architecture in Android Explained: Structure Your Project Like a Professional

How should you structure an Android project? With MVVM: the View renders state, the ViewModel holds screen logic in a lifecycle-safe scope, and the Repository owns data from Room and Retrofit. This guide builds a complete MVVM feature — StateFlow state, SavedStateHandle, use cases, shared ViewModels, fake-based unit tests — plus the five mistakes that break the pattern and viva-ready answers.

Written by Projectech15 min readPublished
For B.E./B.Tech Computer Science and IT final-year students structuring Android app codebases Topics: Kotlin, MVVM, ViewModel, StateFlow, Room, Retrofit
Illustration of a smartphone linked to layered architecture blocks representing the Model, ViewModel, View, and Repository.
Illustration generated for this guide.
In this guide

Open a typical student Android project and you will find a 900-line MainActivity that fetches data, parses JSON, updates views, handles clicks, and formats dates — everything in one file. It works until it does not: a screen rotation wipes the loaded data, a second screen needs the same logic and copies it, and the viva question "walk me through your architecture" has no good answer.

MVVM (Model-View-ViewModel) is the architecture Google recommends for Android apps, and it exists to solve exactly this. The View shows things, the ViewModel holds the screen's state and logic, and the Model (usually a Repository over Room and Retrofit) owns the data. This guide builds a complete MVVM feature from scratch — ViewModel, state, repository, and the wiring between them — plus the mistakes that break the pattern and how interviewers probe it.

The MVVM rule: the View observes state; it never fetches data. The ViewModel holds logic; it never touches a View. The Repository owns data; it never knows about screens.

The three layers and what each owns

Layer Owns Must never
View (Activity/Fragment/Composable) Rendering state, forwarding user events Fetch data, hold business logic, survive rotation (it does not)
ViewModel UI state, user-event handling, coordinating data calls Reference Views, Activities, or Context (leaks); do Android-framework work directly
Model (Repository + data sources) Where data comes from: Room DAO, Retrofit API, or both Know which screen asked; hold UI state

Data flows in one direction: View → ViewModel → Repository → data source, and state flows back as observable streams. The View never calls the Repository directly. This single constraint is what makes the codebase explainable.

An expense splitter app maps cleanly: the View shows balances, the ViewModel computes who owes whom from the event stream, the Repository persists expenses in Room and syncs them via Retrofit.

ViewModel: state that survives rotation

An Activity is destroyed and recreated on every configuration change — rotation, language switch, multi-window resize. Anything stored in the Activity dies with it, which is why student apps reload data (and lose form input) on rotation.

A ViewModel survives configuration changes. The system keeps it alive while the Activity recreates, and clears it only when the screen is genuinely finished:

class ExpenseViewModel(
    private val repository: ExpenseRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ExpenseUiState())
    val uiState: StateFlow<ExpenseUiState> = _uiState.asStateFlow()

    fun loadExpenses(groupId: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val expenses = repository.getExpenses(groupId)
                _uiState.update { it.copy(isLoading = false, expenses = expenses) }
            } catch (e: IOException) {
                _uiState.update { it.copy(isLoading = false, error = "No connection") }
            }
        }
    }

    fun addExpense(description: String, amount: Double, paidBy: String) {
        viewModelScope.launch {
            repository.addExpense(
                Expense(
                    description = description,
                    amount = amount,
                    paidBy = paidBy,
                    createdAt = System.currentTimeMillis()
                )
            )
            // Repository exposes a Flow; the UI updates itself — no manual reload
        }
    }
}

data class ExpenseUiState(
    val isLoading: Boolean = false,
    val expenses: List<Expense> = emptyList(),
    val error: String? = null
)

Key points:

  • One state object (ExpenseUiState) instead of five separate LiveDatas. A single state is impossible to put in an inconsistent combination; five independent observables can disagree with each other.
  • viewModelScope cancels coroutines when the ViewModel clears — no leaked network calls after the user leaves the screen.
  • StateFlow over LiveData for new code: it is Kotlin-native, works in any coroutine context (not just Android), and has clearer threading semantics. LiveData remains fine in existing code — do not rewrite working LiveData just to chase fashion.

The View collects the state and renders it — nothing else:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            if (state.isLoading) showSpinner() else hideSpinner()
            state.error?.let { showError(it) }
            adapter.submitList(state.expenses)
        }
    }
}

repeatOnLifecycle(STARTED) starts collection when the screen is visible and suspends it when it is not — no wasted work, no crashes from updating a stopped Activity.

SavedStateHandle: surviving process death

ViewModels survive rotation, but not process death — when the OS kills your app in the background to reclaim memory, the ViewModel is gone and the user returns to a reset screen. SavedStateHandle persists small amounts of state across process death:

class ExpensesViewModel(
    private val repository: ExpenseRepository,
    private val savedState: SavedStateHandle
) : ViewModel() {

    // survives rotation AND process death
    var groupId: String?
        get() = savedState["groupId"]
        set(value) { savedState["groupId"] = value }

    // or as a StateFlow:
    val searchQuery: StateFlow<String> =
        savedState.getStateFlow("query", "")
}

The rule of thumb: UI state that is cheap to recompute (a loaded list) can reload from the repository; UI state the user typed (a half-written expense form, the current tab, a search query) belongs in SavedStateHandle. Test it with "Don't keep activities" in developer options — the harshest, most honest test of your state handling.

Repository: the single source of truth

The Repository decides where data comes from and the rest of the app does not care. The standard student pattern — Room as the source of truth, network as the refresher — looks like this:

class ExpenseRepository(
    private val dao: ExpenseDao,
    private val api: ApiService
) {

    // UI observes this: always local, always instant, always offline-capable
    fun observeExpenses(groupId: String): Flow<List<Expense>> =
        dao.observeByGroup(groupId)

    suspend fun refreshExpenses(groupId: String) {
        try {
            val remote = api.getExpenses(groupId)
            dao.upsertAll(remote.map { it.copy(isSynced = true) })
        } catch (e: IOException) {
            // Offline: local data stays; the UI already shows it. Log and continue.
        }
    }

    suspend fun addExpense(expense: Expense) {
        val id = dao.insert(expense.copy(isSynced = false))
        try {
            val created = api.createExpense(expense)
            dao.markSynced(id, created.serverId)
        } catch (e: IOException) {
            // Queued locally; a WorkManager sync will pick it up later
        }
    }
}

This is the repository half of the offline-first pattern: writes land locally first (so the UI updates instantly), sync follows when possible. The ViewModel never knows whether the network worked — it just observes the database.

A gym workout tracker is a good example of a repository with two data sources: Room holds the workout history (offline-first), while a Retrofit API supplies the exercise catalogue (refresh-on-open).

Wiring it together: who creates the ViewModel?

ViewModels need constructor arguments (the repository), but the framework instantiates them — so you supply a factory:

class ExpenseViewModelFactory(
    private val repository: ExpenseRepository
) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(ExpenseViewModel::class.java)) {
            return ExpenseViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

// in the Activity/Fragment:
private val viewModel: ExpenseViewModel by viewModels {
    ExpenseViewModelFactory((application as App).expenseRepository)
}

For projects with more than a couple of screens, manual factories become boilerplate — that is the point where Hilt (Dagger's Android wrapper) earns its place by generating the wiring. For a final-year project with 3–6 screens, manual factories are honest, debuggable, and easy to explain in a viva. Adopt Hilt when the manual wiring genuinely hurts, not before.

When the ViewModel gets too big: use cases

A ViewModel that computes split balances, validates input, formats currency, and talks to the repository is doing four jobs. Extract the pure logic into use-case classes — small, testable, framework-free:

class CalculateBalancesUseCase {
    operator fun invoke(expenses: List<Expense>): Map<String, Double> {
        val paid = mutableMapOf<String, Double>()
        val owed = mutableMapOf<String, Double>()
        for (e in expenses) {
            paid[e.paidBy] = (paid[e.paidBy] ?: 0.0) + e.amount
            val share = e.amount / e.splitBetween.size
            for (member in e.splitBetween) {
                owed[member] = (owed[member] ?: 0.0) + share
            }
        }
        return paid.mapValues { (person, total) ->
            total - (owed[person] ?: 0.0)
        }
    }
}

// in the ViewModel:
private val calculateBalances = CalculateBalancesUseCase()

fun settleUp() {
    val balances = calculateBalances(uiState.value.expenses)
    _uiState.update { it.copy(balances = balances) }
}

The use case has no Android imports — it unit-tests in milliseconds with plain JUnit. The ViewModel shrinks to orchestration: call use case, update state. When an evaluator asks "where is the business logic," you point at a named, tested class instead of waving at a Fragment.

Sharing a ViewModel between screens

A master-detail flow (group list → expense list) often needs shared state. Scope the ViewModel to the parent Activity instead of the Fragment:

// both fragments in the same Activity share one instance:
private val viewModel: ExpensesViewModel by activityViewModels {
    ExpenseViewModelFactory((requireActivity().application as App).expenseRepository)
}

activityViewModels() gives every fragment in the Activity the same instance — select a group in one fragment, the detail fragment observes the same state. The alternative (passing IDs through bundles and reloading) works but duplicates loads; shared ViewModels keep one source of truth. Scope deliberately: shared too widely, the ViewModel becomes a global; scoped per screen, each screen owns its state.

Package structure that reflects the architecture

com.example.expenses/
├── data/
│   ├── local/
│   │   ├── AppDatabase.kt
│   │   ├── ExpenseDao.kt
│   │   └── Converters.kt
│   ├── remote/
│   │   ├── ApiService.kt
│   │   └── dto/
│   │       └── ExpenseDto.kt
│   └── repository/
│       └── ExpenseRepository.kt
├── ui/
│   ├── expenses/
│   │   ├── ExpensesFragment.kt
│   │   ├── ExpensesViewModel.kt
│   │   └── ExpensesAdapter.kt
│   └── groups/
│       ├── GroupsFragment.kt
│       └── GroupsViewModel.kt
└── di/
    └── AppContainer.kt        // manual dependency wiring

Feature-first inside ui/, layer-first inside data/. When an evaluator opens the project, the structure narrates the architecture before they read a line of code. A daily-wage job finder app with job listings, applications, and worker profiles fits this layout directly — one ui/ package per feature, one repository per data domain.

The five mistakes that break MVVM

1. Passing a View or Context into the ViewModel. The ViewModel outlives the Activity across rotations — holding the old Activity leaks the entire view hierarchy. If you need resources or navigation, expose events/state and let the View act on them. (The one sanctioned exception is AndroidViewModel, which holds the Application context — safe because the application outlives everything.)

2. Business logic in the Activity. "Just this once, format the date in the Fragment" becomes fifty such onces. Formatting, validation, and decisions live in the ViewModel (or a use-case class); the View renders strings it is given.

3. Calling the repository from the View. It works, until two screens need the same data with the same error handling and the logic is copy-pasted. The ViewModel is the single place screen logic lives.

4. Multiple sources of truth. The UI reads expenses from the ViewModel's state in one place and from a direct DAO call in another — they disagree after a sync. One observable stream per screen, fed by the repository.

5. GlobalScope anywhere. Coroutines launched in GlobalScope ignore the lifecycle and leak. viewModelScope in ViewModels, lifecycleScope in UI, explicit scopes nowhere else.

Testing: the architecture pays for itself here

MVVM's real dividend is testability. A ViewModel with injected dependencies is a plain Kotlin class — no Android framework needed:

class ExpenseViewModelTest {
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    private val testDispatcher = StandardTestDispatcher()
    private lateinit var viewModel: ExpenseViewModel
    private val fakeRepository = FakeExpenseRepository()

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        viewModel = ExpenseViewModel(fakeRepository)
    }

    @Test
    fun `loading expenses updates state`() = runTest(testDispatcher) {
        fakeRepository.expenses = listOf(Expense("Lunch", 250.0, "Ved", 0L))
        viewModel.loadExpenses("group-1")
        advanceUntilIdle()
        assertEquals(1, viewModel.uiState.value.expenses.size)
        assertFalse(viewModel.uiState.value.isLoading)
    }

    @Test
    fun `network failure surfaces an error message`() = runTest(testDispatcher) {
        fakeRepository.shouldFail = true
        viewModel.loadExpenses("group-1")
        advanceUntilIdle()
        assertNotNull(viewModel.uiState.value.error)
    }
}

The FakeExpenseRepository implements the same interface with an in-memory list — no database, no network, deterministic. If your ViewModel is hard to test, the architecture has a leak: something Android-specific crept into a layer that should be plain Kotlin. Students who can demo this test in a viva answer "how did you verify your logic" with evidence, not adjectives.

Testing the repository with fakes

The repository is the other highly testable layer — it coordinates Room and Retrofit, both of which you fake:

class ExpenseRepositoryTest {
    private val fakeDao = FakeExpenseDao()       // in-memory list
    private val fakeApi = FakeApiService()       // canned responses / throws
    private val repository = ExpenseRepository(fakeDao, fakeApi)

    @Test
    fun `offline write stays local and queues for sync`() = runTest {
        fakeApi.shouldFail = true                // simulate airplane mode
        repository.addExpense(Expense("Lunch", 250.0, "Ved", 0L))
        val stored = fakeDao.getAll()
        assertEquals(1, stored.size)
        assertFalse(stored[0].isSynced)          // flagged for later upload
    }

    @Test
    fun `refresh stores server data locally`() = runTest {
        fakeApi.expenses = listOf(Expense("Dinner", 400.0, "Asha", 0L))
        repository.refreshExpenses("group-1")
        assertEquals(1, fakeDao.getAll().size)
    }
}

Two tests prove the offline-first contract: writes survive network failure, and server data lands in local storage. When these pass against fakes, the only remaining risk is the real DAO/API wiring — which the instrumented tests or a manual pass cover. A student who shows this test pyramid (unit tests with fakes → a few instrumented tests → manual testing) has a stronger verification story than most professional demos.

MVVM vs the alternatives (so you can defend the choice)

Architecture Idea Verdict for a student project
No architecture ("MVC" in one Activity) Everything in the Activity Fails at rotation, untestable, unexplainable — do not ship this
MVP Presenter holds logic, talks to a View interface Workable, but presenters need manual lifecycle handling; largely superseded on Android
MVVM ViewModel holds state, View observes Google's recommendation; Jetpack built for it; lifecycle-aware by default
MVI Single immutable state + intents, unidirectional Elegant, but the boilerplate and learning curve exceed what a final-year timeline needs
Clean Architecture (full) Use cases + domain layer + strict boundaries Right for large teams; for a student app, MVVM + repository captures most of the value

The honest student answer: MVVM with a repository layer is the sweet spot — enough structure to be testable and explainable, not so much that the architecture becomes the project.

A note on Compose

Jetpack Compose is Google's current UI toolkit and pairs naturally with MVVM — the View becomes a @Composable function collecting the same StateFlow:

@Composable
fun ExpensesScreen(viewModel: ExpensesViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    if (state.isLoading) {
        CircularProgressIndicator()
    } else {
        ExpenseList(expenses = state.expenses)
    }
}

Everything in this guide transfers unchanged: the ViewModel, the repository, the state object, the use cases. Only the View layer's rendering code differs. If your course still teaches XML layouts, the patterns here apply identically — learn MVVM once, render it either way.

One Compose-specific habit worth building early: state hoisting. Composables should receive state as parameters and emit events as callbacks, rather than reaching for the ViewModel themselves:

@Composable
fun ExpenseList(
    expenses: List<Expense>,
    onExpenseClick: (Expense) -> Unit
) { /* renders only */ }

The screen-level composable collects the ViewModel state and passes slices down. Dumb, reusable UI components on the leaves; state ownership at the top. It is the composable expression of the same separation this whole guide argues for. As a bonus, @Preview functions render these leaf components in Android Studio without launching the app, which makes UI iteration dramatically faster than the old deploy-and-look loop.

Navigation and ViewModel scoping

With the Navigation component, each destination gets its own ViewModel by default — which is correct for independent screens. Two scoping decisions come up repeatedly:

Shared flow, shared ViewModel: a multi-step form (create group → add members → confirm) spanning three destinations should share one ViewModel scoped to the navigation graph, so partial input survives moving between steps:

// scoped to the nav graph instead of the fragment:
private val viewModel: CreateGroupViewModel by navGraphViewModels(R.id.create_group_graph) {
    CreateGroupViewModelFactory(repository)
}

When the user exits the graph, the ViewModel clears — no manual cleanup, no leaked half-filled forms.

Do not share across unrelated destinations. A "global" ViewModel holding the current user, settings, theme, and five screens' state becomes the 900-line Activity all over again, just wearing a different class name. App-wide concerns (logged-in user, preferences) belong in a repository or a dedicated session holder observed by whoever needs them — not in a mega-ViewModel.

Viva questions on MVVM (and strong answers)

  • "Why not put the logic in the Activity?" — "Activities are destroyed on rotation and are hard to unit-test because they need the Android framework. The ViewModel survives configuration changes and is a plain Kotlin class, so the logic is both rotation-safe and testable."
  • "What happens to your ViewModel on rotation? On process death?" — "Rotation: the same instance is retained, state intact. Process death: the instance is destroyed; UI state I marked with SavedStateHandle is restored, and repository data reloads from Room."
  • "Why does the ViewModel not reference the View?" — "It outlives any single View instance across rotations. Holding a View would leak the destroyed Activity and its entire view hierarchy."
  • "Where does the network call happen, and on which thread?" — "In the repository, called from viewModelScope. Retrofit's suspend functions and Room's suspend DAO methods move off the main thread themselves, so the UI thread is never blocked."
  • "How do two screens share data?" — "Either a shared ViewModel scoped to the parent Activity/navigation graph, or both observing the same repository Flow from Room — the database is the shared source of truth."

Checklist

  • Each screen has one ViewModel holding one UI-state object; the View only renders and forwards events.
  • No View, Activity, or Activity-context references inside any ViewModel (AndroidViewModel with application context only where genuinely needed).
  • All data access goes through a repository; the View never touches a DAO or Retrofit service.
  • Coroutines use viewModelScope/lifecycleScope — no GlobalScope, no leaked work across rotation.
  • State collection uses repeatOnLifecycle(STARTED) (or collectAsStateWithLifecycle in Compose).
  • Package structure mirrors the architecture: data/ (local, remote, repository) and ui/ (per feature).
  • At least one ViewModel unit test with a fake repository demonstrates the logic is verified.
  • Rotation preserves loaded data and form state on every screen (the ViewModel test).
  • Multi-step flows use a navigation-graph-scoped shared ViewModel; no single ViewModel is shared across unrelated screens — app-wide concerns live in repositories, not a mega-ViewModel.

Where this fits your project

MVVM is the skeleton the rest of an Android app hangs on: Room is your Model's local half, Retrofit is the remote half, the repository binds them, and FCM feeds server-driven events into the same state stream. An expense splitter demonstrates shared state across screens; a gym workout tracker shows a two-source repository; a daily-wage job finder exercises the feature-package layout at scale. Pick Kotlin as your language (Google's preferred language for Android since 2019), then build on this structure. More Android builds live in the Android branch hub.

More project guides

More in Android