πŸ›οΈ Core Concept Β· ~35 min read Β· Intermediate β†’ Advanced

MVVM Architecture

Why separation of concerns is the most important idea in Android development β€” and how ViewModel, StateFlow, and the Repository pattern turn that idea into a working system.

The problem with no architecture

Picture the classic Android beginner project. One Activity. In it: network calls using AsyncTask, SQLite queries in the same file, business logic tangled through button click handlers, and UI update code scattered between onResume() and callbacks. It works. Until you try to rotate the device β€” and the network call fires again. Or until the designer asks for a small change β€” and you realize you need to touch seven places. Or until someone asks you to write a test β€” and you discover that every method implicitly depends on Context.

This is what Android development looked like for years. Google called it the "God Activity" anti-pattern: an Activity that is simultaneously a network client, a database manager, a state machine, and a UI renderer. It's easy to write and impossible to maintain.

The core problem is coupling. When your UI layer directly owns business logic, and your business logic directly owns data access code, every change ripples everywhere. You can't test logic without spinning up an Activity. You can't swap your database without touching your UI. You can't hand a piece of the code to a teammate without handing them the whole thing.

MVVM solves this by giving each layer one clearly-defined job β€” and enforcing strict rules about which direction communication can flow.

What is MVVM?

MVVM stands for Model–View–ViewModel. It's an architectural pattern that divides your app into three layers, each with a distinct responsibility:

🍽️ Analogy

Think of a restaurant. The View is the waiter β€” they take your order and bring your food, but they don't cook. The ViewModel is the order ticket β€” it captures what you asked for and coordinates the kitchen. The Model is the kitchen β€” it knows recipes and ingredients, but it never sees the dining room. Each role is clean. A new waiter doesn't need to know how to cook. A new chef doesn't need to know how to take orders.

The pattern originated at Microsoft in 2005, designed for WPF and Silverlight. Google officially adopted and adapted it for Android in 2017 with the launch of Architecture Components β€” specifically the ViewModel class and LiveData (later superseded by StateFlow).

πŸ“‹ MVVM β€” the three layers
Layer 1 VIEW Activity / Fragment Jetpack Compose β€’ Renders UI β€’ Observes state β€’ Sends user events β€’ No business logic Layer 2 VIEW MODEL androidx.lifecycle β€’ Holds UI state β€’ Processes events β€’ Survives rotation β€’ No Android views β€’ Easily testable Layer 3 MODEL Repository + Sources β€’ Fetches data β€’ Caches & persists β€’ Business logic β€’ No UI knowledge β€’ Pure Kotlin/Java user events UI state (StateFlow) request data Flow / suspend βœ• View never talks directly to Model

The View layer

The View is everything the user can see and touch: Activities, Fragments, and Compose composables. Its job is simple in principle β€” observe state, render it, and report user interactions back up to the ViewModel. In practice, "simple in principle" requires discipline to maintain.

The defining rule of the View layer is: no business logic here. The View doesn't decide whether a button should be enabled β€” it renders whatever the ViewModel says. It doesn't decide what happens when you tap "Buy" β€” it forwards the tap event to the ViewModel and waits. The View is deliberately dumb. A View that is dumb is a View that is easy to change, easy to test by inspection, and easy to replace entirely (say, when you migrate from XML layouts to Compose).

ProductFragment.kt β€” a properly thin View
class ProductFragment : Fragment(R.layout.fragment_product) { private val vm: ProductViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // β‘  Observe state β€” fragment renders whatever it receives viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { vm.uiState.collect { state -> renderState(state) } } } // β‘‘ Send events β€” fragment reports interactions, does not decide outcomes binding.btnBuy.setOnClickListener { vm.onBuyClicked() } binding.btnWishlist.setOnClickListener { vm.onWishlistClicked() } } private fun renderState(state: ProductUiState) { when (state) { is ProductUiState.Loading -> showLoading() is ProductUiState.Success -> showProduct(state.product) is ProductUiState.Error -> showError(state.message) } } // Note: no network calls, no if/else business rules, no database queries }

Notice what the Fragment does not do: it doesn't check whether the user is logged in, it doesn't decide whether to show or hide the Buy button based on product availability, and it doesn't know where the product data comes from. All of that is the ViewModel's problem.

βœ… Key Practice

Always use repeatOnLifecycle(Lifecycle.State.STARTED) when collecting flows in a Fragment. It automatically cancels collection when the app goes to background and resumes when it comes back β€” preventing unnecessary work and subtle bugs with one-time events.

The ViewModel layer

The ViewModel is the brain of the screen. It holds all the state the View needs, processes all the events the View sends, calls into the Model to fetch or mutate data, and translates the results into UI state that the View can render directly.

What makes a ViewModel a ViewModel β€” rather than just a class β€” is its lifecycle. A ViewModel is scoped to a screen, not to an Activity instance. When you rotate the device, the Activity is destroyed and recreated. The ViewModel is not. It keeps running, holding its state, waiting for the new Activity instance to connect and observe. This is the fundamental insight that makes Android architecture tractable.

πŸ“‹ ViewModel survives configuration changes
time β†’ Activity (Portrait β€” Instance 1) β†Ί rotated! Activity destroyed Activity (Landscape β€” Instance 2) user presses Back β†’ Activity finished Activity finishing ViewModel β€” lives on across rotation cleared() ViewModelStore (owned by Activity) onCleared() called here

The mechanism behind this is the ViewModelStore. Every Activity and Fragment owns a ViewModelStore β€” a simple map from a string key to a ViewModel instance. When you call viewModels(), the framework looks up the ViewModel in the store. If it's there (because you rotated), it returns the existing instance. If it's not there (first launch), it creates a new one. When the Activity truly finishes (user pressed Back, or the OS killed it), the store calls onCleared() on every ViewModel and releases them.

Designing UI state

The ViewModel exposes state as a StateFlow β€” a hot, always-has-a-value stream that the View observes. Good UI state design uses a sealed class to represent every possible screen condition as an explicit type. This makes impossible states unrepresentable and forces you to handle every case in the View.

ProductViewModel.kt β€” state, events, and the ViewModel
// β‘  Sealed class models every possible UI condition sealed class ProductUiState { object Loading : ProductUiState() data class Success(val product: Product, val isWishlisted: Boolean) : ProductUiState() data class Error(val message: String) : ProductUiState() } // β‘‘ One-time events (navigation, snackbar) β€” NOT part of UI state sealed class ProductEvent { data class ShowSnackbar(val message: String) : ProductEvent() data class NavigateToCart(val cartId: String) : ProductEvent() } class ProductViewModel( private val repo: ProductRepository, private val savedState: SavedStateHandle ) : ViewModel() { private val productId = savedState.get<String>("productId")!! // β‘’ Private mutable β€” public read-only (encapsulation) private val _uiState = MutableStateFlow<ProductUiState>(ProductUiState.Loading) val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow() private val _events = Channel<ProductEvent>(Channel.BUFFERED) val events = _events.receiveAsFlow() init { loadProduct() } private fun loadProduct() { viewModelScope.launch { _uiState.value = ProductUiState.Loading repo.getProduct(productId) .catch { e -> _uiState.value = ProductUiState.Error(e.localizedMessage ?: "Error") } .collect { product -> val wishlisted = repo.isWishlisted(productId) _uiState.value = ProductUiState.Success(product, wishlisted) } } } fun onBuyClicked() { viewModelScope.launch { val cartId = repo.addToCart(productId) _events.send(ProductEvent.NavigateToCart(cartId)) } } fun onWishlistClicked() { viewModelScope.launch { repo.toggleWishlist(productId) _events.send(ProductEvent.ShowSnackbar("Added to wishlist")) } } }
⚠️ Common Mistake β€” don't use SharedFlow for UI state

It's tempting to use SharedFlow for everything, but UI state must always have a current value β€” a new subscriber (like after screen rotation) should immediately know whether to show a loading spinner or actual content. StateFlow guarantees this; SharedFlow does not. Use StateFlow for state, and a Channel (or SharedFlow(replay=0)) for one-shot events like navigation or toasts.

SavedStateHandle β€” surviving process death

Rotation is handled automatically by ViewModelStore. But there's a harder problem: process death. When Android kills your app's process to reclaim memory (while the app is in the background), the ViewModel is lost entirely. The user returns expecting to see their previous screen. Without SavedStateHandle, you're starting from scratch.

SavedStateHandle is a key-value map backed by the Bundle that saves instance state. It survives process death and is automatically injected into your ViewModel by the framework. Any value you store in it will be restored even if the process was killed. The rule of thumb: store in SavedStateHandle any value that represents the user's navigation intent (product IDs, search queries, selected filter state) β€” not fetched data, which should be re-fetched.

SavedStateHandle β€” survive process death
class SearchViewModel(private val savedState: SavedStateHandle) : ViewModel() { // This query survives rotation AND process death var searchQuery: String get() = savedState["query"] ?: "" set(value) { savedState["query"] = value } // Or use StateFlow backed by SavedStateHandle β€” best of both worlds val query = savedState.getStateFlow("query", initialValue = "") fun onQueryChanged(q: String) { savedState["query"] = q // persisted immediately } }

Unidirectional Data Flow

MVVM in Android follows a pattern called Unidirectional Data Flow (UDF). Data flows in one direction only β€” down from ViewModel to View as state, and up from View to ViewModel as events. There are no two-way bindings. There are no callbacks from ViewModel to View. The View is completely passive.

This sounds restrictive, but it's liberating. When a bug appears, you know exactly where to look: the View only renders, so if the rendering is wrong the bug is in the ViewModel's state. If the state is wrong, the bug is in the ViewModel's event handling or the Repository. The data flow is a one-way street, so you can read it like a story β€” event arrives, state updates, View re-renders.

πŸ“‹ Unidirectional Data Flow β€” the event-state cycle
View Model β‘  User Event button tap, text input β‘‘ Process validate, call repo update _uiState β‘’ New State Loading / Success / Error β‘£ View Renders Activity / Fragment collects StateFlow vm.onBuyClicked() StateFlow.value = collect { renderState(it) } user sees result, acts again

One thing that trips people up: one-shot events like "show a Snackbar" or "navigate to the next screen" should NOT be modelled as UI state. If you put snackbarMessage: String? in your state, you have to remember to clear it after showing β€” and you'll get bugs where the snackbar reappears on rotation. Instead, use a Channel with BUFFERED capacity for events. A Channel delivers each event exactly once to exactly one receiver, which is the right semantic for navigation and notifications.

The Repository pattern

The "Model" in MVVM is more than a single class β€” it's typically a layer made up of a Repository and one or more data sources. The Repository is the gatekeeper. The ViewModel asks it for data; the Repository decides where to get it from β€” the network, the local database, or an in-memory cache β€” and handles all the reconciliation between those sources.

The reason for this abstraction is testability and flexibility. If the ViewModel called Retrofit directly, you'd have to mock Retrofit in tests and your code would be tightly coupled to one networking library. With a Repository interface, you can inject a FakeProductRepository in tests that returns hardcoded data instantly. You can also swap the networking library (Retrofit β†’ Ktor) without touching the ViewModel.

πŸ“‹ Repository pattern β€” single source of truth
View Model getProduct(id) Flow<Product> Repository if (cache.isValid) return cache val remote = api.fetch() db.insert(remote) return db.observe() ↑ Single Source of Truth ↑ Room DB is canonical β€” always emit from DB, not network Remote API Retrofit / OkHttp ProductApiService fetch if stale Local DB Room / SQLite ProductDao write through emit on change In-Memory Cache TTL-based, optional layer
ProductRepository.kt β€” the single source of truth pattern
interface ProductRepository { fun getProduct(id: String): Flow<Product> suspend fun addToCart(productId: String): String suspend fun toggleWishlist(productId: String) } class ProductRepositoryImpl( private val api: ProductApiService, private val dao: ProductDao ) : ProductRepository { override fun getProduct(id: String): Flow<Product> = flow { // Emit from cache first (fast local read) val cached = dao.getProduct(id) if (cached != null) emit(cached) // Fetch fresh data from network val fresh = api.getProduct(id) // suspend call dao.upsert(fresh) // write to DB emit(fresh) // emit fresh result // OR: the reactive approach β€” emit from DB and let Room push updates // emitAll(dao.observeProduct(id)) // Flow that never completes } override suspend fun addToCart(productId: String): String { val response = api.addToCart(CartRequest(productId)) return response.cartId } override suspend fun toggleWishlist(productId: String) { val current = dao.isWishlisted(productId) if (current) { api.removeWishlist(productId); dao.removeWishlist(productId) } else { api.addWishlist(productId); dao.addWishlist(productId) } } }

The key insight in the repository above: the ViewModel calls getProduct(), and it gets back a Flow. The ViewModel doesn't know or care whether that data came from the database or the network. The repository handled that decision. If you later add a CDN cache or a local file cache, you change the repository β€” nothing else.

MVVM + Clean Architecture

MVVM tells you what the three layers are. Clean Architecture tells you how to structure the Model layer when it grows complex. As your app scales, the Repository layer gains business rules that don't belong to any single repository β€” rules like "apply the discount if the user is premium" or "validate the order before submitting." These rules are Use Cases (also called Interactors).

πŸ“‹ Full Clean Architecture β€” concentric dependency rings
Presentation Layer Activity Β· Fragment Β· Compose Β· ViewModel Domain Layer Pure Kotlin β€” NO Android dependencies Use Cases business rules Entities domain models Repository Interfaces abstractions only β€” no implementations Data Layer Repository impls Β· Room Β· Retrofit Β· DataStore Remote Source Retrofit API Local Source Room DAO Repository Implementation implements the interface from Domain layer DTOs / Mappers network models β†’ domain entities depends on Data β†’ Domain only never Domain β†’ Data Dependency Rule: inner rings know nothing about outer rings

The Dependency Rule is the golden rule of Clean Architecture: code in an inner ring must never depend on code in an outer ring. The Domain layer defines a ProductRepository interface. The Data layer provides ProductRepositoryImpl that fulfills that contract. The Presentation layer calls use cases from the Domain. No one in the Domain ever imports a Retrofit class, a Room annotation, or an Android Activity. This is what makes domain logic testable with plain JUnit β€” no framework required.

GetProductUseCase.kt β€” pure domain logic, no Android, no framework
// Domain layer β€” pure Kotlin, easily unit-tested class GetProductUseCase(private val repo: ProductRepository) { operator fun invoke(id: String): Flow<Result<Product>> = repo.getProduct(id) .map { product -> // business rule: apply discount if user is premium member if (product.userIsPremium) product.copy(price = product.price * 0.9) else product } .map { Result.success(it) } .catch { e -> emit(Result.failure(e)) } } // ViewModel calls the use case, not the repository directly class ProductViewModel( private val getProduct: GetProductUseCase ) : ViewModel() { fun loadProduct(id: String) { viewModelScope.launch { getProduct(id).collect { result -> _uiState.value = result.fold( onSuccess = { ProductUiState.Success(it) }, onFailure = { ProductUiState.Error(it.message ?: "Error") } ) } } } }

Wiring it all up β€” Dependency Injection

MVVM with Clean Architecture introduces a lot of objects: ViewModel, Use Cases, Repository, API service, DAO. Someone has to create them and hand them to each other. That someone should not be the classes themselves β€” if a class creates its own dependencies, you can't swap them in tests. This is the job of Dependency Injection.

Hilt (Google's DI library, built on Dagger) is the standard choice for Android. It uses annotations to describe the dependency graph and generates the wiring code at compile time. No reflection, no runtime overhead.

Hilt wiring β€” the full dependency graph for one feature
// 1. Define the module β€” how to build each dependency @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideRetrofit(): Retrofit = Retrofit.Builder() .baseUrl(BuildConfig.API_URL) .addConverterFactory(GsonConverterFactory.create()) .build() @Provides @Singleton fun provideProductApi(retrofit: Retrofit): ProductApiService = retrofit.create(ProductApiService::class.java) } @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { // Binds the interface to its implementation β€” the key DI move @Binds @Singleton abstract fun bindProductRepository( impl: ProductRepositoryImpl ): ProductRepository } // 2. ViewModel β€” Hilt injects everything automatically @HiltViewModel class ProductViewModel @Inject constructor( private val getProduct: GetProductUseCase, savedState: SavedStateHandle ) : ViewModel() // 3. Activity β€” one annotation, done @AndroidEntryPoint class ProductActivity : AppCompatActivity() { private val vm: ProductViewModel by viewModels() // Hilt provides the ViewModel with all its dependencies injected }

Common mistakes and how to avoid them

Holding Context in ViewModel

A ViewModel outlives the Activity. If it holds a reference to the Activity's Context, that Context leaks β€” the Activity can't be garbage collected even after it's destroyed. If you need application-level context (for SharedPreferences, system services, etc.), use AndroidViewModel which provides application, or better β€” inject your dependencies via Hilt so the ViewModel never needs to touch Context directly.

⚠️ Memory Leak β€” never do this

class ProductViewModel(private val context: Context) β€” if context is an Activity, this is a leak. The ViewModel lives longer than the Activity. Use AndroidViewModel for app-level context, or inject collaborators via DI.

Putting LiveData in the Repository

LiveData is a UI-layer concept β€” it's lifecycle-aware because it needs to know about the lifecycle of whoever is observing it. The Repository has no lifecycle. Use plain Flow in the Repository and Data layers. The ViewModel converts it to a StateFlow (or LiveData via asLiveData() if you must) for the View.

Business logic in the View

The View should have zero if-then logic about data. "Show this button only if the user has more than 0 items in cart" β€” that's business logic. Compute it in the ViewModel, expose it as a boolean in your state class (data class UiState(val showBuyButton: Boolean, ...)), and let the View blindly bind to it. This way the View is a pure function of state.

One ViewModel for the whole app

A shared ViewModel scoped to an Activity makes it easy to share data between Fragments, but it's a trap. The ViewModel balloons in size, all state is live all the time (wasting memory), and every Fragment re-renders when any state changes. Prefer per-screen ViewModels, and use a shared ViewModel only for truly shared, screen-spanning state (like the current user). Pass data between screens via navigation arguments, not a global ViewModel.

Testing MVVM

The payoff for all this separation is testability. Each layer can be tested in isolation:

ProductViewModelTest.kt β€” testing without a real repository
@OptIn(ExperimentalCoroutinesApi::class) class ProductViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() // swaps Main dispatcher for tests private val fakeRepo = mockk<ProductRepository>() @Test fun `loading product emits Success state`() = runTest { val product = Product(id = "p1", name = "Laptop", price = 999.0) coEvery { fakeRepo.getProduct("p1") } returns flowOf(product) coEvery { fakeRepo.isWishlisted("p1") } returns false val savedState = SavedStateHandle(mapOf("productId" to "p1")) val vm = ProductViewModel(fakeRepo, savedState) val state = vm.uiState.value assertThat(state).isInstanceOf(ProductUiState.Success::class.java) assertThat((state as ProductUiState.Success).product.name).isEqualTo("Laptop") } @Test fun `network error emits Error state`() = runTest { coEvery { fakeRepo.getProduct(any()) } returns flow { throw IOException("No network") } val vm = ProductViewModel(fakeRepo, SavedStateHandle(mapOf("productId" to "p1"))) val state = vm.uiState.value assertThat(state).isInstanceOf(ProductUiState.Error::class.java) assertThat((state as ProductUiState.Error).message).contains("No network") } }

Notice the test spins up a real ViewModel with a fake repository β€” no Activity, no Espresso, no emulator. The test runs in milliseconds. This is the point. When your architecture is correct, your tests are fast, isolated, and reliable.

How this connects to system design interviews

When an interviewer asks you to design an Android system β€” an e-commerce app, a chat client, a feed β€” they're implicitly asking about your architecture too. MVVM is the expected foundation. But what distinguishes senior-level answers is the reasoning, not the naming of layers.

The questions that reveal architectural depth: How do you handle process death in a payment flow? (SavedStateHandle + server-side idempotency.) How do you keep the UI reactive when multiple data sources update simultaneously? (Room as a reactive single source of truth, combine operators in the ViewModel.) How do you test the business logic that determines whether a discount applies? (Use Case in the Domain layer, pure JUnit test, no mocking of Android.) How does the ViewModel know when to stop a network request? (viewModelScope cancels everything when the ViewModel is cleared.)

These answers all flow naturally from a well-understood MVVM architecture. The pattern is not just an organizational preference β€” it's a set of guarantees: guaranteed lifecycle safety, guaranteed testability, guaranteed separation of concerns. When you understand the why behind each boundary, the system design answers write themselves.

🎯 Interview Summary

View β€” passive renderer, observes StateFlow, forwards events, no logic.
ViewModel β€” holds UI state as StateFlow, processes events, calls Use Cases, scoped to screen not Activity instance, survives rotation via ViewModelStore.
Repository β€” single source of truth, abstracts data sources, exposes Flow, interface in Domain / impl in Data.
Use Case β€” one business rule per class, pure Kotlin, testable without mocking Android.
SavedStateHandle β€” ViewModel state that survives process death, backed by instance state Bundle.
UDF β€” events flow up, state flows down, sealed classes make impossible states unrepresentable.