Android Architecture
Irbisa · cheatsheetSeptember 13, 2026

Android Architecture

MVVM, Clean Architecture, Repository, ViewModel, UseCase, UDF

Middle Developer20 itemscompressed for a skim
  1. 01

    How is a modern Android app layered, and what rule keeps those layers from tangling?

    Medium

    Google'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>>
    }
    …
  2. 02

    What is the Repository pattern? Why use it on Android?

    Medium

    A 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)
    …
  3. 03

    What is unidirectional data flow (UDF) in Android UI?

    Medium

    Unidirectional 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
    )
    …
  4. 04

    How would you split a large Android app into Gradle modules, and which module depends on which?

    Medium

    A 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
    …
  5. 05

    What makes Android code hard to unit-test, and which patterns fix it?

    Medium

    Android 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()
    …
  6. 06

    MVP, MVVM and MVI on Android — what actually differs between them, and which one does a Compose app end up with?

    Medium

    They 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)
    }
    …
  7. 07

    What belongs inside a UseCase, and when is adding one just ceremony?

    Medium

    A 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
    ) {
    …
  8. 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?

    Medium

    A 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
    }
    …
  9. 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>?

    Hard

    Combine the sources into the single state object the screen renders, then share the result with stateIn so a rotation does not restart every query underneath.

    class CheckoutViewModel @Inject constructor(
      profiles: ProfileRepository,
      cart: CartRepository,
      flags: FeatureFlags,
      private val placeOrder: PlaceOrder
    ) : ViewModel() {
    …
  10. 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?

    Medium

    One-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. 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?

    Medium

    Navigation 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. 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?

    Hard

    Migrate 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. 13

    Your API returns a UserDto, Room stores a UserEntity and the screen renders a UserUiModel — when are three classes right, and when are they just mapping code?

    Medium

    A 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. 14

    A reviewer deletes Dispatchers.IO from every viewModelScope.launch(...) in your pull request — what rule are they applying?

    Medium

    Main-safety belongs to the callee: a suspend function 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. 15

    Your UiState carries errorMessage: String built with context.getString(...) inside the ViewModel — what does that break?

    Medium

    Text 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. 16

    A snackbar reads "HttpException: HTTP 422" and one screen keeps spinning after the user leaves it — what is wrong with the error handling?

    Medium

    Errors 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 CancellationException through 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. 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?

    Hard

    To 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. 18

    Your test calls viewModel.load() and asserts on state.value, but always sees the initial Loading — what can cause that?

    Hard

    Nothing has run yet: the coroutine launched into viewModelScope is still queued on the test's Main dispatcher, and if the state came from stateIn(..., 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. 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?

    Hard

    PagingData is 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. 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?

    Hard

    An 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>
    }
    …