Skip to content
Commitwise
Menu
Android Development

Architecture: MVVM, MVI and Clean

Structure an Android app in UI, domain and data layers, make the local database the source of truth behind a repository, decide when use cases are worth it, choose between several StateFlows and one UI state, and split the app into feature and core modules without over-engineering it.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The orders screen of a shop app was written in a week: the ViewModel calls Retrofit, maps the JSON, computes which orders can be cancelled, and exposes the result. It works. Then the order detail screen needs the same “can be cancelled” rule and copies it. Then the app should work offline. Then a test is needed for the cancel rule, and it turns out to require a fake HTTP server.

None of this is a crisis, but each change costs more than it should. Architecture is the set of decisions about where code lives and which parts may depend on which, so that the tenth feature costs about as much as the first. This tutorial follows Google’s recommended app architecture, which most Android teams use in some form, and shows where MVVM, MVI and “Clean” fit in it.

New to some of these? Open what you need, or skip ahead.

Basics:What is a ViewModel and UI state?

A ViewModel holds a screen’s state and logic and survives configuration changes like rotation. It exposes UI state, usually as a StateFlow, and the composable draws whatever that state says. The ViewModel and unidirectional data flow tutorial covers it.

Basics:What is a repository?

A repository is the one class the rest of the app asks for a kind of data (“orders”), hiding whether it comes from the network, a database or a cache. The Repository pattern tutorial covers it in four languages.

Part Not started

Three layers and one source of truth

Think of a restaurant. Waiters (the UI layer) talk to guests and show them what’s available. The kitchen’s recipes (the domain layer) decide how dishes are made, the same way whichever waiter takes the order. The pantry and suppliers (the data layer) make sure ingredients are there. Waiters never go to the supplier themselves.

In an Android app:

  • UI layer: composables and ViewModels. The ViewModel turns data into UI state and handles user actions.
  • Domain layer (optional): use cases, small classes holding a business rule several screens need, like “can this order be cancelled?”.
  • Data layer: repositories, which decide where data comes from, and data sources below them: a Retrofit API, a Room database, DataStore.

Each layer only uses the one below it. A composable never calls Retrofit; a repository never knows about screens.

Google’s guide to app architecture defines a UI layer (UI elements plus state holders exposing immutable UI state through UDF), an optional domain layer (use cases encapsulating reusable or complex business logic, typically one public operator fun invoke), and a data layer (repositories as the single entry point per data type, coordinating data sources and exposing Flows, with the local database as the source of truth for offline-capable data). That’s “Clean Architecture” in its pragmatic Android form: dependencies point inward toward data and domain, and the domain is plain Kotlin, but there’s usually no separate domain model for every entity and no mandatory use case for every call. The two disciplines that matter most: the UI never touches data sources directly, and every piece of data has one source of truth that every screen observes.

// Data source: Room
@Dao
interface OrderDao {
    @Query("SELECT * FROM orders WHERE customerId = :customerId ORDER BY createdAt DESC")
    fun observeOrders(customerId: String): Flow<List<OrderEntity>>

    @Upsert suspend fun upsertAll(orders: List<OrderEntity>)
}

// Repository: the only class the rest of the app asks for orders
class OrderRepository @Inject constructor(
    private val dao: OrderDao,
    private val api: OrdersApi,
    @IoDispatcher private val io: CoroutineDispatcher,
) {
    fun orders(customerId: String): Flow<List<Order>> =
        dao.observeOrders(customerId).map { rows -> rows.map(OrderEntity::toDomain) }

    suspend fun refresh(customerId: String) = withContext(io) {
        val fresh = api.orders(customerId)                 // network DTOs
        dao.upsertAll(fresh.map(OrderDto::toEntity))       // the Flow above emits automatically
    }
}

// Domain layer: a rule two screens need
class CanCancelOrderUseCase @Inject constructor(private val clock: Clock) {
    operator fun invoke(order: Order): Boolean =
        order.status == OrderStatus.PLACED &&
            Duration.between(order.createdAt, clock.instant()) < Duration.ofHours(1)
}

// UI layer: the ViewModel combines them into UI state
@HiltViewModel
class OrdersViewModel @Inject constructor(
    private val repository: OrderRepository,
    private val canCancel: CanCancelOrderUseCase,
    session: Session,
) : ViewModel() {
    private val customerId = session.customerId

    val uiState: StateFlow<OrdersUiState> = repository.orders(customerId)
        .map { orders -> OrdersUiState.Loaded(orders.map { OrderRow(it, canCancel = canCancel(it)) }) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OrdersUiState.Loading)

    init { viewModelScope.launch { runCatching { repository.refresh(customerId) } } }
}
  • The screen observes the database, through the repository. Whatever is in Room is shown immediately, even offline.
  • refresh() downloads fresh orders and writes them to Room. Because the screen observes Room, it updates by itself; nobody has to “tell” it.
  • Any other screen that changes an order (cancelling it on the detail screen) writes to Room too, so the list updates as well.
  • CanCancelOrderUseCase holds the cancel rule once. The list and the detail screen both use it, and it can be tested with a fake clock in a few lines.
Spot the bugAn empty orders screen on the subway

Tap the line with the bug.

@HiltViewModel
class OrdersViewModel @Inject constructor(
    private val api: OrdersApi,
    session: Session,
) : ViewModel() {
    private val _uiState = MutableStateFlow<OrdersUiState>(OrdersUiState.Loading)
    val uiState = _uiState.asStateFlow()

    init {
        viewModelScope.launch {
            _uiState.value = try {
                OrdersUiState.Loaded(api.orders(session.customerId).map { it.toRow() })
            } catch (e: IOException) {
                OrdersUiState.Error
            }
        }
    }
}

Predict the output

With the fixed design, the detail screen cancels order 1042 by calling repository.cancel(1042), which calls the API and then updates the row in Room. The orders list is still in the back stack. What does the list show when the user goes back?

  • UI layer (composables + ViewModels, immutable UI state, UDF), optional domain layer (use cases), data layer (repositories over data sources).
  • Dependencies point toward data/domain; UI never touches Retrofit, Room or DataStore directly.
  • Local database as source of truth: repositories expose Flows from Room and refresh() writes network data into it; all screens stay consistent.
  • Use cases for rules that are reused or complex, with injected clocks and dispatchers for testability.
  • Refresh failures are extra state, not a replacement for cached data.

Check this part

  1. Which layer is allowed to call the Retrofit API?

  2. Why does the list update after an order is cancelled on another screen?

  3. When is a use case worth adding?

Split the app into a UI layer (composables and ViewModels), an optional domain layer (use cases with reusable business rules) and a data layer (repositories over data sources). Dependencies point toward data and domain, never back to the UI. The local database is the source of truth: repositories expose Flows from it and refresh it from the network.

Part Not started

MVVM or MVI, how much Clean, and modules

Inside the UI layer, teams usually pick one of two styles for the ViewModel:

  • MVVM style: several separate values (orders, isRefreshing, errorMessage), each a StateFlow, and plain functions for actions (refresh(), cancel(id)).
  • MVI style: one UiState object describing the whole screen, and one entry point for actions (onAction(OrdersAction.Refresh)).

Both are fine. One state object is easier to keep consistent (you can’t show “loading” and “error” at the same time by accident), so many teams prefer it, even if they call it MVVM.

As apps grow, they’re also split into modules: separate Gradle projects such as :feature:orders, :feature:checkout, :core:data, :core:ui. Modules build in parallel and only rebuild when they change, and a feature module simply can’t reach into another feature’s internals.

The MVVM-versus-MVI distinction on Android has narrowed to state shape and action dispatch: a single immutable UiState built with combine and stateIn versus several flows, and named functions versus a sealed Action type with a reducer. A single state prevents impossible combinations and makes screenshot tests trivial; several flows recompose more selectively and avoid giant copy calls. The common pitfalls are elsewhere: one-off events modeled as replaying state, combine over many flows producing intermediate states, and Clean ceremony (a domain model, a DTO, an entity and three mappers for a field that’s displayed once; a GetOrdersUseCase that only calls repository.orders()). Modularization (Now in Android’s :feature:*, :core:* split, with api/impl modules where a feature must be navigable without depending on its implementation) pays off in build time and ownership boundaries for larger teams; for a small app it’s mostly overhead.

data class OrdersUiState(
    val orders: List<OrderRow> = emptyList(),
    val isRefreshing: Boolean = false,
    val refreshFailed: Boolean = false,
    val filter: OrderFilter = OrderFilter.All,
)

sealed interface OrdersAction {
    data object Refresh : OrdersAction
    data class SetFilter(val filter: OrderFilter) : OrdersAction
    data class Cancel(val id: OrderId) : OrdersAction
}

@HiltViewModel
class OrdersViewModel @Inject constructor(
    private val repository: OrderRepository,
    private val canCancel: CanCancelOrderUseCase,
    private val session: Session,
) : ViewModel() {
    private val filter = MutableStateFlow(OrderFilter.All)
    private val refresh = MutableStateFlow(RefreshStatus.Idle)

    val uiState: StateFlow<OrdersUiState> = combine(
        repository.orders(session.customerId), filter, refresh,
    ) { orders, filter, refresh ->
        OrdersUiState(
            orders = orders.filter(filter::matches).map { OrderRow(it, canCancel(it)) },
            isRefreshing = refresh == RefreshStatus.Running,
            refreshFailed = refresh == RefreshStatus.Failed,
            filter = filter,
        )
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OrdersUiState())

    fun onAction(action: OrdersAction) {
        when (action) {
            OrdersAction.Refresh -> viewModelScope.launch {
                refresh.value = RefreshStatus.Running
                refresh.value = runCatching { repository.refresh(session.customerId) }
                    .fold({ RefreshStatus.Idle }, { RefreshStatus.Failed })
            }
            is OrdersAction.SetFilter -> filter.value = action.filter
            is OrdersAction.Cancel -> viewModelScope.launch { repository.cancel(action.id) }
        }
    }
}

Production puzzles

Predict the output

uiState uses stateIn(viewModelScope, SharingStarted.Eagerly, …) and the repository Flow polls the server every 30 seconds while collected. The user opens Orders and then goes to another tab for an hour; the ViewModel stays in the back stack. What happens?

Predict the output

A team adds GetOrdersUseCase, ObserveOrderUseCase, RefreshOrdersUseCase and CancelOrderUseCase, each one line calling the repository, plus OrderDomain, OrderUi and mappers between them and the existing OrderEntity and OrderDto. What did the extra layer buy?

Modules

:app                       wires everything together, owns the NavHost
:feature:orders            screens + ViewModels for orders (depends on :core:data, :core:ui)
:feature:checkout          screens + ViewModels for checkout
:core:data                 repositories, Room, Retrofit (internal), exposes repository interfaces
:core:domain               use cases with shared business rules (pure Kotlin)
:core:model                plain data classes used everywhere
:core:ui                   design system: theme, buttons, cards
:core:testing              fakes and test rules shared by all modules
  • Features don’t depend on each other. Navigating from orders to checkout goes through routes defined in a small shared module or wired up in :app.
  • internal keeps implementation classes (the Retrofit service, DAOs) invisible outside :core:data.
  • Convention plugins (in build-logic) keep every module’s Gradle setup identical in a few lines.
Exerciseeasy

Where does it go?

Place each piece of code in a layer (UI, domain or data): (1) formatting a price as “€12.50” for a row; (2) “free shipping if the cart total is over €50”, used by the cart and checkout screens; (3) caching the product catalog in Room; (4) deciding whether to show a “Retry” button; (5) mapping the API’s total_cents field to a Money value.

›

Ask who needs it: one screen’s display, several screens’ rules, or how data is stored and fetched?

SolutionTry it yourself first
  1. UI layer: formatting for display, in the ViewModel’s mapping to UI state (or a formatter in :core:ui).
  2. Domain layer: a use case (or a function in the domain module), since two screens need the same rule.
  3. Data layer: the product repository and its Room data source.
  4. UI layer: part of the screen’s UI state (refreshFailed = true).
  5. Data layer: mapping DTOs to app models happens in the repository, so nothing above it ever sees total_cents.
  • Single immutable UiState via combine + stateIn vs several flows: consistency vs selectivity; actions as functions or a sealed type.
  • stateIn(WhileSubscribed(5_000)) with collectAsStateWithLifecycle: stops upstream in the background, survives rotation. Eagerly keeps work alive while in the back stack.
  • Avoid Clean ceremony: no pass-through use cases or mappers for identical shapes; domain layer is optional.
  • Modularize (:feature:*, :core:*, convention plugins, internal) for build speed and boundaries in larger teams; features don’t depend on features.

Check this part

  1. What is the main advantage of one UiState object per screen?

  2. Why WhileSubscribed(5_000) rather than Eagerly for screen state?

  3. Should feature modules depend on each other?

Expose one immutable UI state per screen (MVI style) or a few independent StateFlows (classic MVVM); combine flows deliberately. Avoid Clean Architecture ceremony: pass-through use cases and a mapper for every layer. Split large apps into feature and core modules with clear APIs to speed up builds and enforce boundaries.

Key takeaways

  • UI, domain and data layers; each layer only uses the one below it.
  • The database is the source of truth; the network refreshes it.
  • Use cases hold business rules that several screens share.
  • One UI state per screen keeps the screen consistent.
  • Google’s layered architecture is pragmatic Clean: optional domain, repositories as entry points.
  • Offline-first by default: observe Room, refresh from the network.
  • stateIn(WhileSubscribed) and lifecycle-aware collection.
  • Avoid ceremony; modularize when team size and build times justify it.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.