How to Structure a Jetpack Compose App: UI, State, and Data Layers

Short answer: organize a Compose app around responsibilities, not around individual composables. Keep composables focused on rendering and user events, put screen state and screen-related logic in a state holder such as a
ViewModel, and expose application data through repositories. Add a domain layer only when it removes real complexity or duplication.
Jetpack Compose does not require a particular folder structure. It does, however, make boundaries visible: a composable is easiest to reuse and test when it receives state and callbacks instead of reaching into a repository or owning every piece of application state.
The structure in this guide works for a small app and can grow into feature modules later. It follows Android’s current architecture guidance: at least a UI layer and a data layer, with an optional domain layer between them. The official architecture guide describes those layers as guidelines to adapt to the app, not a mandatory template.
A practical Compose architecture
Use this dependency direction:
Activity / NavHost
|
v
Screen route -> screen state holder -> repository -> data source
|
v
stateless composablesThe arrows describe who calls whom. Data flows back toward the UI as observable state, while user actions travel upward as events:
state down -------------------------> composables
events up <------------------------- state holderThis is unidirectional data flow (UDF). A screen can render the same UI in a preview or test because its rendering function does not need to know how data is fetched.
The four responsibilities
UI layer: render state and emit events
The UI layer contains screen composables, reusable UI components, navigation wiring, and UI-specific state. A composable should answer “what should be shown for this state?” rather than “how do I load data from the network?”
Separate a route-level composable from a rendering composable:
@Composable
fun TasksRoute(
viewModel: TasksViewModel,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
TasksScreen(
uiState = uiState,
onAction = viewModel::onAction,
)
}
@Composable
fun TasksScreen(
uiState: TasksUiState,
onAction: (TasksAction) -> Unit,
) {
when {
uiState.isLoading -> LoadingContent()
uiState.errorMessage != null -> ErrorContent(
message = uiState.errorMessage,
onRetry = { onAction(TasksAction.Retry) },
)
else -> TaskList(
tasks = uiState.tasks,
onToggleTask = { id -> onAction(TasksAction.ToggleTask(id)) },
)
}
}collectAsStateWithLifecycle() is the lifecycle-aware way to collect a Flow in an Android UI. It is provided by the lifecycle Compose integration; add the matching lifecycle-runtime-compose dependency to the app module.
The route obtains the state holder and connects events. The screen renders state. Smaller children receive only the values and callbacks they actually use. This is the same state-hoisting principle used throughout state hoisting in Compose.
State holder: prepare screen state
A screen-level ViewModel is a useful state holder when the screen has business logic or must survive configuration changes. It should expose immutable state and provide intent-like methods for events:
data class Task(
val id: String,
val title: String,
val isComplete: Boolean,
)
data class TasksUiState(
val isLoading: Boolean = true,
val tasks: List<Task> = emptyList(),
val errorMessage: String? = null,
)
sealed interface TasksAction {
data object Retry : TasksAction
data class ToggleTask(val id: String) : TasksAction
}
class TasksViewModel(
private val repository: TasksRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(TasksUiState())
val uiState: StateFlow<TasksUiState> = _uiState.asStateFlow()
fun onAction(action: TasksAction) {
when (action) {
TasksAction.Retry -> loadTasks()
is TasksAction.ToggleTask -> toggleTask(action.id)
}
}
private fun loadTasks() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
runCatching { repository.observeTasks().first() }
.onSuccess { tasks ->
_uiState.value = TasksUiState(isLoading = false, tasks = tasks)
}
.onFailure { error ->
_uiState.update {
it.copy(isLoading = false, errorMessage = error.message)
}
}
}
}
private fun toggleTask(id: String) {
viewModelScope.launch { repository.toggleTask(id) }
}
}This example is intentionally small. A production screen may derive uiState from a repository Flow with stateIn, handle errors with a stable UI error type, and trigger mutations without collecting a one-shot first() value. The important boundary is that the composable does not own the repository or coordinate the data operation.
Do not pass a ViewModel through every child composable. Pass state and event callbacks instead. Android’s UI layer guidance recommends this separation because it keeps UI elements reusable and makes previews and tests simpler.
Data layer: repositories and data sources
The data layer owns application data and the rules for accessing it. A repository can combine a local database, network client, or other source and hide those implementation details from the UI:
interface TasksRepository {
fun observeTasks(): Flow<List<Task>>
suspend fun toggleTask(id: String)
}
class DefaultTasksRepository(
private val local: TasksLocalDataSource,
private val remote: TasksRemoteDataSource,
) : TasksRepository {
override fun observeTasks(): Flow<List<Task>> = local.observeTasks()
override suspend fun toggleTask(id: String) {
local.toggleTask(id)
// A sync policy, if needed, belongs here rather than in a composable.
remote.enqueueTaskUpdate(id)
}
}Keep data-source classes narrow: a network data source talks to the API, and a database data source talks to the database. The repository decides which source is authoritative, how to combine them, and which model the rest of the app receives. See the Android data layer recommendations for the responsibilities of repositories and data sources.
For larger apps, use separate DTO, database, and UI/domain models when their shapes or lifecycles differ. Mapping at the boundary prevents an API response change from leaking into every composable.
Optional domain layer: only for real reuse
Android describes the domain layer as optional. Add it when business logic is complex or reused by multiple ViewModels:
class CompleteTaskUseCase(
private val repository: TasksRepository,
) {
suspend operator fun invoke(id: String) {
repository.toggleTask(id)
}
}Do not create a one-line use case for every repository method just to make the folder tree look “clean.” A use case should represent a meaningful action or rule. The domain layer guide recommends one responsibility per use case and keeping mutable state out of these classes.
A feature-oriented package layout
Organize by feature once the app has more than a few screens. A feature can own its screen, state holder, and UI components while shared design-system pieces remain separate:
app/src/main/java/com/example/app/
├── MainActivity.kt
├── navigation/
│ └── AppNavHost.kt
├── core/
│ ├── designsystem/
│ └── model/
├── data/
│ ├── local/
│ ├── remote/
│ └── repository/
├── domain/ # optional
│ └── task/
└── feature/
├── tasks/
│ ├── TasksRoute.kt
│ ├── TasksScreen.kt
│ ├── TasksViewModel.kt
│ └── TasksUiState.kt
└── settings/
├── SettingsRoute.kt
└── SettingsScreen.ktPackage-by-feature makes ownership obvious. It also lets you extract a feature into a Gradle module later without first untangling a large global ui/ package. For a tiny app, a simpler package layout is fine; structure should reduce navigation cost, not add ceremony.
Where common state belongs
Use the smallest owner that satisfies the state’s lifetime:
| State | Good owner | Example |
|---|---|---|
| Used by one composable | The composable with remember | Expanded/collapsed details |
| Shared by sibling composables | Their lowest common composable parent | Selected tab |
| Needed for screen business logic | Screen ViewModel | Loaded tasks and save errors |
| Shared across screens or sessions | Repository or app-level state holder | Signed-in user |
Use rememberSaveable when a UI value should survive activity recreation and can be saved in a supported way. Use a ViewModel for screen state derived from business logic. Persist important application data in the data layer rather than relying on any UI lifetime. The Compose state-saving guidance explains this distinction.
Navigation is a boundary, too
Keep the NavController near the app-level NavHost. Screen composables should expose callbacks such as onOpenTask(id) rather than receiving the controller directly. Pass a small route argument—usually an ID—and let the destination load its data.
That approach works naturally with the type-safe routes in Navigation Compose, and avoids putting large, stale model objects into the back stack.
Common structure mistakes
Putting repository calls in composables
This makes recomposition, lifecycle, loading, and error handling difficult to reason about. Move application-data access to a state holder and expose renderable state.
Making every composable accept a ViewModel
This couples reusable UI to Android lifecycle classes. Keep one route-level integration point, then pass plain state and callbacks down.
Treating ViewModel as a universal singleton
Scope a ViewModel to the screen, navigation graph, or activity that owns its state. If data must be shared more broadly, put the source of truth in a repository and collect it where needed.
Adding layers before there is a problem
An optional domain layer, many interfaces, and multiple Gradle modules can slow a small project. Start with a clear UI/data boundary and introduce another layer when duplication, complexity, or team ownership justifies it.
Confusing UI state with navigation state
The back stack answers which destination is visible. A screen UI state answers what that destination displays. Keep route arguments small and let the destination’s state holder load the current content.
A simple decision checklist
Before adding a class or package, ask:
- Does this code render UI, own screen state, apply business rules, or access data?
- Who needs to read and change this state, and how long should it live?
- Can the rendering composable be previewed with a plain
TasksUiState? - Can the state holder be tested without starting an Activity?
- Is this abstraction removing duplication or only satisfying a preferred folder diagram?
If the answers are clear, the exact package names matter much less. A well-structured Compose app has explicit ownership, one direction for state and events, and boundaries that can change without rewriting the UI.