blog / architecture
Android App Architecture: A Practical Modern Guide
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.
What is the recommended Android app architecture?
Google’s guidance boils down to three layers with strict dependency direction:
| Layer | Contains | Depends on | Required? |
|---|---|---|---|
| UI | Composables, ViewModels, UI state classes | Domain or data | Yes |
| Domain | Use cases (single-purpose classes) | Data | No — add it when needed |
| Data | Repositories, data sources (Room, DataStore, network) | Nothing in your app | Yes |
Three principles hold it together:
- Single source of truth. Every piece of data has exactly one owner that is allowed to change it. For anything persisted, that owner is a repository, and usually the database behind it.
- Unidirectional data flow (UDF). State goes down from the ViewModel to the UI; user actions go up as function calls. The UI never mutates state directly.
- UI driven by data models. The screen is a function of a state object, so it survives rotation, process death (with a little help) and is trivial to preview and test.
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:
stateIn(..., WhileSubscribed(5_000), ...)keeps the upstream flows alive for five seconds after the last collector leaves. That covers a rotation (the new activity re-subscribes well within five seconds) but stops database and network work when the app sits in the background. This is the policy Google’s own samples use.- No
Context, noActivity, noResourcesin the constructor. ViewModels outlive activities, so holding one leaks it. If you need a string resource, expose an ID or a sealed message type and resolve it in the UI. - Actions are plain functions.
onRefresh()andonDelete()are the “events up” half of UDF.
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:
- Turn the event into state (
userMessageabove, orisSaved = true). - Let the UI react to it.
- 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
| StateFlow | LiveData | Compose mutableStateOf | |
|---|---|---|---|
| Operators (map, combine, debounce) | ✓ full Flow API | Limited | ✗ (use derivedStateOf) |
| Works in the data layer | ✓ | Discouraged | ✗ UI only |
| Testable without Android | ✓ | Needs an executor rule | ✓ with snapshot care |
| Lifecycle-aware in Compose | Via collectAsStateWithLifecycle | Via observeAsState | Built 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:
- Be main-safe. Every public suspend function must be safe to call from the main thread. Room and Retrofit suspend functions already are; if you parse a large file or do CPU work, wrap it in
withContext(Dispatchers.Default)inside the repository, not in the ViewModel. - Map at the boundary.
NetworkNote(JSON shape) andNoteEntity(table shape) stay inside the data layer. The rest of the app sees a plainNote. This stops a backend field rename from rippling into your composables.
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:
- The same logic that combines several repositories appears in two or more ViewModels.
- A ViewModel has grown a long private function that is really business logic, not UI logic, and you want to test it on its own.
- Non-UI callers (a worker, a widget, a notification) need the same logic as a screen.
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:
- Feature modules never depend on each other.
:appdepends on all features and wires navigation between them. If a feature needs to open another, pass a lambda or a navigation key in from:app. - Dependencies point inward:
feature→core:data→core:database/core:network→core:model. Nothing incoreknows features exist. - Use
implementation, notapi, unless a module’s public signatures expose another module’s types. This is what keeps rebuilds incremental. - Centralise build config in convention plugins and a version catalog, so twenty modules do not each carry their own copy of
compileSdkand Compose setup. See Gradle version catalogs for the catalog half.
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?
- ViewModels: plain JVM unit tests with a fake repository and
kotlinx-coroutines-test. Assert onuiState.valueafter triggering an action. BecausestateInusesWhileSubscribed, start a collector inbackgroundScopeso the flow is active. - Repositories: fake the network data source and use an in-memory Room database.
- UI: Compose tests against the stateless screen with hand-built state.
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
- Collecting flows with
collectAsState()in Android UI, so work keeps running in the background. - ViewModels in reusable components. ViewModels belong at screen or navigation-destination level. A reusable card or dialog should take state and lambdas, or use a plain state holder class.
- Exposing
MutableStateFlowpublicly, which lets the UI write state and breaks UDF. Keep the mutable one private. - Using
AndroidViewModelto get aContext. Move that dependency into the data layer, where it can be injected with@ApplicationContext. - Fetching data in
initunconditionally. Prefer a cold flow withstateIn; it starts when the UI subscribes and stops when it leaves. - A god “AppRepository”. One repository per data type keeps each one small and testable.
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.