Android Architecture
MVVM, Clean Architecture, Repository, ViewModel, UseCase, UDF
- 01
How is a modern Android app layered, and what rule keeps those layers from tangling?
MediumGoogle's recommended architecture is a layered design with strictly unidirectional data flow.
// Data layer interface PostsRepo { suspend fun list(): List<Post> fun observe(): Flow<List<Post>> } … - 02
What is the Repository pattern? Why use it on Android?
MediumA Repository is a single class that owns access to a domain entity, hiding WHERE the data comes from (network, DB, cache, in-memory).
// Domain model — owned by domain layer data class User(val id: Long, val name: String, val avatar: String?) // Wire DTO — owned by remote data class UserResponse(val id: Long, val full_name: String, val avatar_url: String?) fun UserResponse.toDomain() = User(id, full_name, avatar_url) … - 03
What is unidirectional data flow (UDF) in Android UI?
MediumUnidirectional data flow means state travels down from the ViewModel and events travel back up from the UI.
data class CartUiState( val items: List<CartItem> = emptyList(), val total: Money = Money.ZERO, val isLoading: Boolean = false, val error: String? = null ) … - 04
How would you split a large Android app into Gradle modules, and which module depends on which?
MediumA large app is normally split into one thin app module, one module per feature, and a set of shared core modules.
// settings.gradle.kts include(":app") include(":core:domain", ":core:data", ":core:network", ":core:database", ":core:designsystem", ":core:common") include(":feature:home", ":feature:profile", ":feature:cart", ":feature:checkout") // :feature:home/build.gradle.kts … - 05
What makes Android code hard to unit-test, and which patterns fix it?
MediumAndroid code becomes untestable when it reaches out for its dependencies instead of receiving them.
import kotlinx.coroutines.test.* import kotlin.test.* class HomeViewModelTest { private val testDispatcher = StandardTestDispatcher() … - 06
MVP, MVVM and MVI on Android — what actually differs between them, and which one does a Compose app end up with?
MediumThey differ in how the UI learns that something changed: a presenter calls methods on a view, a ViewModel exposes observable state, and MVI narrows that state to one immutable object changed only through declared actions.
// MVP — the presenter drives an interface; state is implicit and duplicated in the view interface CartView { fun showLoading() fun showItems(items: List<CartItem>) fun showError(message: String) } … - 07
What belongs inside a UseCase, and when is adding one just ceremony?
MediumA UseCase is one business operation expressed as a single callable class, and it earns its place when the logic is shared between screens or too involved for a ViewModel to own.
// One operation, one call site, no Android imports class PlaceOrder @Inject constructor( private val cart: CartRepository, private val orders: OrderRepository, private val pricing: PricingRepository ) { … - 08
Do you model a screen's state as a sealed Loading/Success/Error hierarchy or as one data class with flags? What breaks with each?
MediumA sealed hierarchy makes mutually exclusive phases impossible to confuse, while a single data class handles the far more common case where content, a refresh and an error are all true at the same time.
// Mutually exclusive phases — a first load with nothing to show yet sealed interface ProfileUiState { data object Loading : ProfileUiState data class Error(val message: UiText, val canRetry: Boolean) : ProfileUiState data class Ready(val profile: Profile) : ProfileUiState } … - 09
A screen needs the profile, the cart and a feature flag, each arriving as its own repository Flow. How do you turn those into one
StateFlow<UiState>?HardCombine the sources into the single state object the screen renders, then share the result with
stateInso a rotation does not restart every query underneath.class CheckoutViewModel @Inject constructor( profiles: ProfileRepository, cart: CartRepository, flags: FeatureFlags, private val placeOrder: PlaceOrder ) : ViewModel() { … - 10
After a successful save the screen should navigate back and show a snackbar. Where does that decision live, and why do such events sometimes fire twice?
MediumOne-off effects misbehave because state is meant to be re-read while a command is meant to be consumed once, so anything replayed to a new collector runs a second time.
sealed interface EditorEvent { data class Saved(val id: Long) : EditorEvent data class Failed(val message: String) : EditorEvent } class EditorViewModel @Inject constructor(private val repo: NoteRepo) : ViewModel() { … - 11
A detail screen's ViewModel needs the id of the item it shows. How does that value get in, and what do you do when it is not a navigation argument?
MediumNavigation arguments reach a ViewModel through its
SavedStateHandle, which the framework fills in for you and which survives process death, so the common case needs no factory at all.@Serializable data class ProfileRoute(val userId: Long, val fromSearch: Boolean = false) @HiltViewModel class ProfileViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, … - 12
You inherit a Fragment-and-XML app with the business logic sitting in the Fragments. How do you get it onto Compose and MVVM without a rewrite?
HardMigrate screen by screen from the leaves inwards, adding a ViewModel first and Compose second, so that every commit is shippable.
// Step 2: logic leaves the Fragment, the XML stays exactly where it is class OrdersFragment : Fragment(R.layout.fragment_orders) { private val vm: OrdersViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val binding = FragmentOrdersBinding.bind(view) … - 13
Your API returns a
UserDto, Room stores aUserEntityand the screen renders aUserUiModel— when are three classes right, and when are they just mapping code?MediumA second model earns its keep only when two layers would otherwise be forced to change together; every other mapper is maintenance you pay for nothing.
// data layer: shaped by the wire @Serializable data class UserDto( @SerialName("display_name") val displayName: String? = null, @SerialName("created_at") val createdAt: String? = null ) … - 14
A reviewer deletes
Dispatchers.IOfrom everyviewModelScope.launch(...)in your pull request — what rule are they applying?MediumMain-safety belongs to the callee: a
suspendfunction must be safe to call from the main thread, so the dispatcher is chosen by the code that actually blocks, not by the ViewModel that calls it.// ❌ the caller guesses the dispatcher, and hides the one call that needs it class BadViewModel(private val repo: OrderRepository) : ViewModel() { fun load() = viewModelScope.launch(Dispatchers.IO) { // Retrofit is already main-safe _state.update { it.copy(orders = repo.orders()) } } } … - 15
Your
UiStatecarrieserrorMessage: Stringbuilt withcontext.getString(...)inside the ViewModel — what does that break?MediumText resolved inside a ViewModel is frozen against the configuration that existed at that moment, so it stops following the app's language, font scale and theme the instant either changes.
// ❌ the sentence is resolved once, in the wrong layer, in whatever locale was live then @HiltViewModel class BadSaveViewModel @Inject constructor( @ApplicationContext private val context: Context ) : ViewModel() { fun save() = viewModelScope.launch { … - 16
A snackbar reads "HttpException: HTTP 422" and one screen keeps spinning after the user leaves it — what is wrong with the error handling?
MediumErrors have to be translated at the same boundary the data is: the repository turns transport failures into a domain type the UI can handle exhaustively, and whatever it uses to catch them must let
CancellationExceptionthrough untouched.sealed interface DataError { data object Offline : DataError data object Unauthorized : DataError data class Invalid(val fields: List<String>) : DataError data class Unexpected(val cause: Throwable) : DataError } … - 17
A three-step checkout shares one ViewModel, and a user who abandons it and comes back later still sees the old cart — where was that ViewModel scoped?
HardTo the Activity, which only dies with the task; state shared by a flow belongs to the flow's own nested navigation graph, because that graph's back-stack entry is destroyed the moment the flow is popped.
@Serializable data object CheckoutGraph @Serializable data object Cart @Serializable data object Shipping @Serializable data object Payment @Composable … - 18
Your test calls
viewModel.load()and asserts onstate.value, but always sees the initialLoading— what can cause that?HardNothing has run yet: the coroutine launched into
viewModelScopeis still queued on the test'sMaindispatcher, and if the state came fromstateIn(..., WhileSubscribed(...))its upstream never started at all because the test is not a collector.class MainDispatcherRule( private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() ) : TestWatcher() { override fun starting(description: Description) = Dispatchers.setMain(dispatcher) override fun finished(description: Description) = Dispatchers.resetMain() } … - 19
A paged list restarts from page one on every rotation, and changing a filter chip leaves duplicated rows — what is wrong with the Paging 3 wiring?
HardPagingDatais a one-shot stream of page events, not state:cachedIn(viewModelScope)is what makes it re-collectable across a rotation, and it must be the last operator in the chain.// data layer: Room generates the PagingSource; the DTO never leaves this file @Dao interface ItemDao { @Query("SELECT * FROM items WHERE category = :category ORDER BY rank") fun pagingSource(category: String): PagingSource<Int, ItemEntity> } … - 20
Every class in your data layer has an interface with exactly one implementation — which of those interfaces earn their keep, and which are noise?
HardAn interface earns its keep where something genuinely varies — a module boundary, a second real implementation, or a test seam you cannot get any other way — and nowhere else.
// ✅ earns it: this is the boundary of :core:auth, and :feature:login compiles against it interface SessionRepository { val session: Flow<Session?> suspend fun signIn(credentials: Credentials): Outcome<Session> } …