AppMama

blog / architecture

Android App Architecture: A Practical Modern Guide

· 11 min read · AppMama

Modern Android app architecture fits in one sentence: a UI layer that renders immutable state, a data layer of repositories that own your app’s data, and an optional domain layer in between when logic gets reused. State flows down, events flow up, and coroutines and Flow connect the layers. Everything else, from Hilt to modules, is plumbing around that idea.

This guide shows what each layer looks like in real Kotlin and Compose code, where people usually go wrong, and when the extra structure (use cases, modules) is worth it. It follows Google’s official architecture guide, with opinions on where it leaves you to choose.

Google’s guidance boils down to three layers with strict dependency direction:

LayerContainsDepends onRequired?
UIComposables, ViewModels, UI state classesDomain or dataYes
DomainUse cases (single-purpose classes)DataNo — add it when needed
DataRepositories, data sources (Room, DataStore, network)Nothing in your appYes

Three principles hold it together:

The recommendations page marks a clear data layer, a clear UI layer, repositories, coroutines and flows, and lifecycle-aware state collection as strongly recommended. The domain layer is only “recommended in big apps”. Take that hint seriously.

How should the UI layer work?

The UI layer has two parts: a state holder (a ViewModel at screen level) and the UI elements that render its state. The ViewModel exposes one StateFlow of a UI state class; the screen collects it with collectAsStateWithLifecycle().

Start by modelling what the screen shows, not what the backend returns:

feature/notes/NotesUiState.kt

data class NotesUiState(
    val notes: List<NoteItem> = emptyList(),
    val isLoading: Boolean = true,
    val isRefreshing: Boolean = false,
    val userMessage: String? = null,
)

data class NoteItem(val id: Long, val title: String, val updatedLabel: String)

Use a data class with flags when parts of the screen can coexist (a list that is also refreshing and also showing a message). Use a sealed interface with Loading, Success and Error when the states are truly exclusive. Both are fine; mixing them in one screen is where it gets messy.

The ViewModel turns repository flows into that state:

feature/notes/NotesViewModel.kt

@HiltViewModel
class NotesViewModel @Inject constructor(
    private val repository: NotesRepository,
) : ViewModel() {

    private val isRefreshing = MutableStateFlow(false)
    private val userMessage = MutableStateFlow<String?>(null)

    val uiState: StateFlow<NotesUiState> =
        combine(
            repository.observeNotes(),
            isRefreshing,
            userMessage,
        ) { notes, refreshing, message ->
            NotesUiState(
                notes = notes.map { it.toItem() },
                isLoading = false,
                isRefreshing = refreshing,
                userMessage = message,
            )
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = NotesUiState(),
        )

    fun onRefresh() {
        viewModelScope.launch {
            isRefreshing.value = true
            try {
                repository.refresh()
            } catch (e: IOException) {
                userMessage.value = "Couldn't refresh. Check your connection."
            } finally {
                isRefreshing.value = false
            }
        }
    }

    fun onDelete(id: Long) {
        viewModelScope.launch { repository.delete(id) }
    }

    fun onMessageShown() {
        userMessage.value = null
    }
}

A few details in that snippet matter more than they look:

The screen itself splits into a thin route that knows about the ViewModel and a stateless screen that only knows about state and lambdas:

feature/notes/NotesScreen.kt

@Composable
fun NotesRoute(viewModel: NotesViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    NotesScreen(
        uiState = uiState,
        onRefresh = viewModel::onRefresh,
        onDelete = viewModel::onDelete,
        onMessageShown = viewModel::onMessageShown,
    )
}

@Composable
fun NotesScreen(
    uiState: NotesUiState,
    onRefresh: () -> Unit,
    onDelete: (Long) -> Unit,
    onMessageShown: () -> Unit,
) {
    val snackbarHostState = remember { SnackbarHostState() }
    uiState.userMessage?.let { message ->
        LaunchedEffect(message) {
            snackbarHostState.showSnackbar(message)
            onMessageShown()
        }
    }
    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
        // list, pull-to-refresh, empty state...
    }
}

collectAsStateWithLifecycle() lives in androidx.lifecycle:lifecycle-runtime-compose. Unlike plain collectAsState(), it stops collecting when the lifecycle drops below STARTED, so a backgrounded app is not still processing every database emission. Use it for every flow you collect in Android UI code; keep collectAsState() for multiplatform code.

The stateless NotesScreen is what you put in @Previews and Compose UI tests. You can feed it any state without a ViewModel, a database or Hilt.

What is unidirectional data flow, and how do you handle one-off events?

UDF means there is one path in each direction. State is produced in one place and consumed read-only below it; changes are requested by calling functions upward. If two places can write the same state, you no longer have UDF, and you will eventually see a screen that disagrees with itself.

The hard case is the “one-off event”: show a snackbar, navigate after saving. The tempting fix is a Channel or SharedFlow of events from the ViewModel. Google’s guidance explicitly marks not doing this as strongly recommended, because an event emitted while nobody is collecting (during a configuration change, for example) can be lost. Instead:

  1. Turn the event into state (userMessage above, or isSaved = true).
  2. Let the UI react to it.
  3. Have the UI tell the ViewModel it was handled (onMessageShown()), which clears it.

For navigation triggered by a button tap, skip the ViewModel entirely: the UI already knows the user tapped, so call the navigation lambda directly. Only route through state when the navigation depends on the result of business logic, such as “navigate once the save succeeded”.

StateFlow vs LiveData vs mutableStateOf in a ViewModel

StateFlowLiveDataCompose mutableStateOf
Operators (map, combine, debounce)✓ full Flow APILimited✗ (use derivedStateOf)
Works in the data layer✓Discouraged✗ UI only
Testable without Android✓Needs an executor rule✓ with snapshot care
Lifecycle-aware in ComposeVia collectAsStateWithLifecycleVia observeAsStateBuilt in

Use StateFlow for ViewModel output in new code. It is the same type your repositories already return, so combine and stateIn chain naturally. mutableStateOf in a ViewModel works and is fine for text field state that only that screen cares about, but it ties the ViewModel to the Compose runtime and loses the Flow operators. LiveData is only worth keeping in existing View-based screens.

What does a repository do in the data layer?

A repository is the single public entry point to one type of data. It hides where the data comes from, resolves conflicts between sources, and exposes two kinds of API: flows for reading and suspend functions for one-shot writes. Google recommends creating repositories even when there is only one data source, and that is good advice: it gives you one seam for caching, mapping and fakes in tests.

The most robust pattern is offline-first: the database is the source of truth, the network only refreshes it.

core/data/OfflineFirstNotesRepository.kt

interface NotesRepository {
    fun observeNotes(): Flow<List<Note>>
    suspend fun refresh()
    suspend fun delete(id: Long)
}

class OfflineFirstNotesRepository @Inject constructor(
    private val dao: NoteDao,
    private val api: NotesApi,
) : NotesRepository {

    override fun observeNotes(): Flow<List<Note>> =
        dao.observeAll().map { rows -> rows.map(NoteEntity::asExternalModel) }

    override suspend fun refresh() {
        val remote = api.getNotes()
        dao.upsertAll(remote.map(NetworkNote::asEntity))
    }

    override suspend fun delete(id: Long) {
        dao.deleteById(id)
        // queue the remote delete, for example with WorkManager
    }
}

Because the UI only ever reads from dao.observeAll(), a refresh, a delete, or a background sync all update the screen the same way: the database changes and the flow emits. No callbacks, no manual “reload”.

Two rules keep the data layer healthy:

For choosing the storage behind a repository, see Room vs DataStore vs SharedPreferences.

When should you add a domain layer?

Add a domain layer when you notice one of these, and not before:

A use case is a small class named verb + noun + UseCase, with a single operator fun invoke, no mutable state, and the same main-safety rule as repositories:

core/domain/GetNotesWithLabelsUseCase.kt

class GetNotesWithLabelsUseCase @Inject constructor(
    private val notesRepository: NotesRepository,
    private val labelsRepository: LabelsRepository,
) {
    operator fun invoke(): Flow<List<NoteWithLabels>> =
        combine(
            notesRepository.observeNotes(),
            labelsRepository.observeLabels(),
        ) { notes, labels ->
            val byId = labels.associateBy { it.id }
            notes.map { note -> NoteWithLabels(note, note.labelIds.mapNotNull(byId::get)) }
        }
}

The ViewModel then calls getNotesWithLabels() as if it were a function. Do not force every repository call through a use case “for consistency”; a use case that only forwards one call adds a file and a test and buys nothing. Let ViewModels talk to repositories directly until a use case earns its place. Also note that if one Room query with a relation could do the join, a repository method is better than a use case.

Android’s “domain layer” is not the same thing as the domain layer in Clean Architecture. You do not need entity interfaces, interactors and presenters, or a pure-Kotlin module per layer, to follow Google’s guidance.

How does dependency injection fit in?

Every class above takes its dependencies in the constructor. That is the only rule that really matters: constructor injection everywhere, so every class can be built in a test with fakes. Something still has to build the graph; for apps with several ViewModels, WorkManager, or navigation-scoped ViewModels, Google recommends Hilt. Small apps can wire a manual container in the Application class. The trade-offs between Hilt and Koin are covered in Essential Android libraries for a new app.

Scope sparingly. A @Singleton is right for the database, the HTTP client and repositories that cache in memory. Use cases and mappers should be unscoped.

How should you modularize an Android app?

Start with one :app module and packages that mirror the layers (ui/notes, data, domain). Split into Gradle modules when build times hurt, when several people step on each other, or when you want the compiler to enforce boundaries. A layout that scales well:

app/                 # Application, MainActivity, navigation graph, DI wiring
feature/notes/       # NotesRoute, NotesScreen, NotesViewModel
feature/settings/
core/model/          # plain Kotlin data classes: Note, Label
core/data/           # repositories (interfaces + implementations)
core/database/       # Room database, DAOs, entities
core/network/        # Retrofit or Ktor service, network models
core/ui/             # shared composables, theme
build-logic/         # convention plugins shared by all modules

The rules that make this pay off:

Do not modularize a two-screen app. The overhead of extra build files is real, and a well-packaged single module is easy to split later if the layers are already clean.

Clean layers matter far more than module count. A single module with a stateless UI, screen-level ViewModels exposing StateFlow, and repositories as the only owners of data is already modern Android architecture.

How do you test each layer?

Prefer fakes to mocks. An InMemoryNotesRepository backed by a MutableStateFlow is ten lines, reads clearly, and does not break when you rename a method argument.

Common Android architecture mistakes

If you are still deciding on the UI toolkit underneath all this, Jetpack Compose vs XML covers that choice; everything in this article assumes Compose, which Google recommends for new apps.

Frequently asked questions

Is MVVM still the recommended architecture for Android?

Google’s guidance is effectively MVVM with unidirectional data flow: a ViewModel exposes one observable UI state and receives user actions as function calls. Some teams call the same thing MVI when every action is a sealed event type. The layers and principles matter more than the label.

Do I need Clean Architecture for an Android app?

No. The official architecture only requires a UI layer and a data layer; the domain layer is optional and is not the same as Clean Architecture’s domain layer. Add use cases when logic is reused or a ViewModel grows too complex, not up front.

Should a ViewModel expose one StateFlow or several?

Prefer a single uiState property so the screen always renders a consistent snapshot. If a screen shows several unrelated pieces of data that update independently, multiple StateFlow properties are acceptable.

Why use WhileSubscribed(5000) with stateIn?

It keeps the upstream flow running for five seconds after the UI stops collecting, which survives a configuration change without restarting work. When the app stays in the background longer than that, collection stops and resources are released.

When should I split my app into multiple Gradle modules?

When build times or team size start to hurt, or when you want the compiler to enforce layer boundaries. A small app with clean packages is easy to split later; splitting a tangled app is not.