Modern MVVM Architecture in Android: Implementing Unidirectional Data Flow, StateFlow, and Clean Architecture with Jetpack Compose
Modern Android architecture has transitioned from imperative UI mutations and fragmented lifecycle callbacks to declarative, reactive, and lifecycle-aware systems. Building resilient applications requires a structured combination of Unidirectional Data Flow (UDF), Kotlin Coroutines StateFlow, Clean Architecture boundaries, and Jetpack Compose's compiler stability model. This guide explores the architectural mechanics, thread safety paradigms, runtime trade-offs, state synchronization patterns, and scalable file organization necessary to design production-grade Android systems.
1. Unidirectional Data Flow (UDF) in Compose and MVVM
Unidirectional Data Flow (UDF) enforces a single direction for state and event propagation, where state flows downward from state holders to the UI and events bubble upward. In Android MVVM with Compose, the ViewModel acts as the single source of truth producing immutable state, while composable functions remain pure rendering projections. This separation eliminates synchronization bugs, simplifies unit testing, and establishes predictable rendering cycles.
+-----------------------------------------------+
| ViewModel |
| (Single Source of Truth & State Production) |
+-----------------------------------------------+
| ^
| State Downstream | Events Upstream
| (StateFlow<UiState>) | (User Actions / Callbacks)
v |
+-----------------------------------------------+
| Composable Screen Root |
| (State Collection & Hoisting) |
+-----------------------------------------------+
| ^
| State Props | Event Lambdas
v |
+-----------------------------------------------+
| Leaf Composable Tree |
| (Pure Declarative Rendering) |
+-----------------------------------------------+The official Android UI Layer and Unidirectional Data Flow guide emphasizes modeling UI state as immutable data classes or sealed hierarchies. By representing the entire visual state of a screen in a single container, the UI becomes a deterministic projection of that state object:
// UI State Model
sealed interface UserFeedUiState {
data object Loading : UserFeedUiState
data class Success(
val feedItems: List<FeedItemUiModel>,
val isRefreshing: Boolean = false,
val errorMessage: String? = null
) : UserFeedUiState
data class Error(val throwable: Throwable) : UserFeedUiState
}
// User Actions / Intents
sealed interface UserFeedUiEvent {
data object Refresh : UserFeedUiEvent
data class BookmarkItem(val itemId: String) : UserFeedUiEvent
data class DismissError(val errorId: String) : UserFeedUiEvent
}In this architecture, composables do not mutate properties directly. Instead, leaf composables accept an immutable state snapshot and emit higher-order functions (event callbacks) to their parent. This enables pure state hoisting, making UI components previewable, modular, and easy to unit test.
2. Kotlin Coroutines StateFlow Mechanics: Conflation and Equality
StateFlow is a hot, state-retaining flow that requires an initial value and maintains a replay cache of size one for active collectors. It applies automatic conflation and structural equality checks (Any.equals()) to drop intermediate values and prevent redundant recompositions when data has not changed. Understanding these mechanics is essential for preventing missed emissions and avoiding stale UI state when using mutable collections or reference comparisons.
class UserFeedViewModel(
private val getFeedUseCase: GetFeedUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow<UserFeedUiState>(UserFeedUiState.Loading)
val uiState: StateFlow<UserFeedUiState> = _uiState.asStateFlow()
fun onEvent(event: UserFeedUiEvent) {
when (event) {
is UserFeedUiEvent.Refresh -> reloadFeed()
is UserFeedUiEvent.BookmarkItem -> toggleBookmark(event.itemId)
is UserFeedUiEvent.DismissError -> clearError()
}
}
private fun reloadFeed() {
viewModelScope.launch {
_uiState.update { current ->
if (current is UserFeedUiState.Success) {
current.copy(isRefreshing = true)
} else {
UserFeedUiState.Loading
}
}
// Execute domain logic...
}
}
}Understanding Conflation Semantics
Because StateFlow maintains only the latest emitted value in its buffer (replay = 1), rapid consecutive emissions can result in intermediate state values being dropped (conflated) before a slower collector processes them.
For continuous UI state streams, this conflation is desirable: the UI only needs to render the most up-to-date state. However, because emissions depend on Any.equals(), mutating an internal collection inside a data class without altering its structural identity will fail to trigger an emission. UI state models must always be implemented using immutable data structures and updated using .copy() operations.
Refer to the official guide on State Production and StateFlow in Android for detailed production guidelines.
3. Lifecycle-Aware State Collection: collectAsStateWithLifecycle vs collectAsState
collectAsStateWithLifecycle manages flow collection according to the Android host lifecycle, stopping collection when the UI falls below the STARTED state. Unlike standard collectAsState(), which remains active across composition boundaries, lifecycle-aware collection prevents resource waste when the app is in the background. Pairing this with SharingStarted.WhileSubscribed(5_000) halts upstream database queries and network calls when no subscribers are active.
+-----------------------------------------------------------------------------------+
| Activity / Fragment Lifecycle |
| |
| [ON_CREATE] ---> [ON_START] ---------------------> [ON_STOP] ---> [ON_DESTROY] |
| | ^ |
| | Lifecycle >= STARTED | Lifecycle < STARTED |
| v | |
| +-----------------------------------------------------------------------------+ |
| | collectAsStateWithLifecycle() [Active] | [Coroutine Cancelled/Paused]| |
| | Upstream Flow active | No background CPU/resource | |
| | Emits state updates to Compose | allocation | |
| +-----------------------------------------------------------------------------+ |
| |
| +-----------------------------------------------------------------------------+ |
| | collectAsState() [Unsafe in Background] | |
| | Upstream Flow remains ACTIVE even when UI is not visible (unless destroyed) | |
| +-----------------------------------------------------------------------------+ |
+-----------------------------------------------------------------------------------+The underlying implementation of collectAsStateWithLifecycle() from the androidx.lifecycle.compose package wraps collection within repeatOnLifecycle(Lifecycle.State.STARTED):
@Composable
fun UserFeedRoute(
viewModel: UserFeedViewModel = viewModel(),
onNavigateToDetail: (String) -> Unit
) {
// Correct: Lifecycle-aware flow collection
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
UserFeedScreen(
uiState = uiState,
onEvent = viewModel::onEvent,
onItemClick = onNavigateToDetail
)
}When an application is minimized or placed behind another activity, collectAsStateWithLifecycle() cancels the underlying collector coroutine. If your ViewModel uses stateIn(SharingStarted.WhileSubscribed(5000), ...), the upstream data stream (such as a database query or network polling loop) detects that there are zero active subscribers after the 5-second stop timeout and halts upstream work. Using plain collectAsState() prevents this subscription cancellation, causing unnecessary background resource and battery consumption.
Detailed lifecycle collection patterns can be reviewed in the official Lifecycle-Aware Coroutine Collection in Android documentation.
4. Main-Safety Inversion and Coroutine Dispatching Architecture
Main-safety inversion requires data sources and repositories to manage their own coroutine dispatchers rather than shifting that responsibility to calling layers. Because viewModelScope executes on Dispatchers.Main.immediate by default, background operations such as disk I/O and heavy JSON parsing must switch to Dispatchers.IO or Dispatchers.Default internally. This design pattern ensures that UI calls remain non-blocking, eliminates main-thread stalls, and standardizes thread safety across the codebase.
// Data Layer: Repository guarantees Main-Safety
class DefaultFeedRepository(
private val remoteDataSource: FeedRemoteDataSource,
private val localDataSource: FeedLocalDataSource,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : FeedRepository {
override suspend fun getFeed(): List<FeedItemEntity> = withContext(ioDispatcher) {
// Disk / Network I/O safely offloaded from the caller's thread
val cached = localDataSource.getCachedFeed()
if (cached.isNotEmpty()) {
return@withContext cached
}
val remote = remoteDataSource.fetchFeed()
localDataSource.insertFeed(remote)
remote
}
}Modern Android libraries such as Room and Retrofit handle thread switching internally for suspend functions, making explicit dispatcher switching unnecessary in those specific database and network calls. However, for manual file I/O, heavy JSON deserialization, or intensive cryptography, repositories must wrap operations in withContext(Dispatchers.IO) or withContext(Dispatchers.Default).
This design ensures that ViewModels and Use Cases can invoke repository methods directly from Dispatchers.Main.immediate without causing dropped frames or blocking the UI thread.
// ViewModel executes cleanly on Main.immediate
class FeedViewModel(
private val feedRepository: FeedRepository
) : ViewModel() {
fun loadContent() {
viewModelScope.launch {
// Safe call: repository handles its own dispatching
val items = feedRepository.getFeed()
_uiState.value = UserFeedUiState.Success(items.map { it.toUiModel() })
}
}
}5. Memory Scoping, ViewModel Lifetime, and State Hoisting
ViewModel instances are scoped to a ViewModelStoreOwner and survive configuration changes until their associated navigation destination or Activity is destroyed. To maintain modularity and enable Compose Previews, composable functions should accept primitive or immutable state models rather than direct ViewModel references. State hoisting separates UI rendering from state production, making composables reusable, decoupled, and straightforward to test in isolation.
// Bad Practice: Tightly couples leaf composable to ViewModel and ViewModelStoreOwner
@Composable
fun BadFeedItem(viewModel: UserFeedViewModel, itemId: String) {
Button(onClick = { viewModel.onEvent(UserFeedUiEvent.BookmarkItem(itemId)) }) {
Text("Bookmark")
}
}
// Architectural Best Practice: State Hoisting with Lambdas
@Composable
fun FeedItemRow(
item: FeedItemUiModel,
onBookmarkClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = item.title, style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = { onBookmarkClick(item.id) }) {
Icon(
imageVector = if (item.isBookmarked) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Bookmark"
)
}
}
}By decoupling leaf composables from the ViewModel instance, you can render previews using mock data without instantiating mock ViewModelStoreOwner structures:
@Preview(name = "Feed Item Row - Bookmarked", showBackground = true)
@Composable
private fun FeedItemRowPreview() {
MaterialTheme {
FeedItemRow(
item = FeedItemUiModel(id = "1", title = "Architectural Patterns in Android", isBookmarked = true),
onBookmarkClick = {}
)
}
}For complete state hoisting and architecture guidelines, review the Jetpack Compose State and Architecture Guidance.
6. Compose Runtime Stability and Smart Recomposition Optimization
The Compose compiler evaluates parameter types as stable, immutable, or unstable to determine whether a composable can be skipped during recomposition. Standard Kotlin collection interfaces like List<T> are treated as unstable by default, which can cause unnecessary re-executions unless stabilized with @Immutable wrappers or compiler configuration. Deferring state reads to the layout or draw phases via lambda modifiers further optimizes performance by bypassing the composition phase entirely.
| Classification | Definition / Behavior | Common Types / Examples |
|---|---|---|
@Immutable |
The value of any public property will never change after instantiation. | Primitive types (Int, String), enum entries, sealed classes with immutable fields. |
@Stable |
Public properties may be mutable, but the Compose runtime is notified when changes occur via snapshot state. | MutableState<T>, types annotated with @Stable whose mutations trigger Compose notifications. |
Unstable |
The compiler cannot guarantee immutability across recompositions; functions accepting these cannot be skipped. | Standard standard library collections (List<T>, Set<T>, Map<T>), classes in non-Compose external modules. |
Standard Kotlin collections like List<T> are defined as interfaces that cannot guarantee immutability at runtime (e.g., an underlying ArrayList can be mutated). Consequently, the Compose compiler marks functions with List<T> parameters as unstable by default unless stability configuration files or stable wrapper types are used:
// Option 1: Immutable wrapper
@Immutable
data class ImmutableListWrapper<T>(val items: List<T>)
// Option 2: Annotate UI State model directly
@Immutable
data class FeedUiState(
val items: List<FeedItemUiModel> = emptyList(),
val isLoading: Boolean = false
)Phase Deferral: Bypassing the Composition Phase
Jetpack Compose operates in three primary phases:
- Composition (Determining what to show)
- Layout (Determining where to place items)
- Draw (Rendering pixels onto the canvas)
Reading state during the Composition phase causes the entire composable function to re-execute when that state changes. Deferring state reads to the Layout or Draw phase using lambda-based modifiers bypasses the Composition phase entirely:
// Inefficient: State read occurs in Composition phase on every scroll offset change
@Composable
fun InefficientScrollHeader(scrollOffset: State<Int>) {
Box(
modifier = Modifier.offset(y = with(LocalDensity.current) { scrollOffset.value.toDp() })
)
}
// Optimized: State read deferred to Layout phase via lambda modifier
@Composable
fun OptimizedScrollHeader(scrollOffset: () -> Int) {
Box(
modifier = Modifier.offset {
IntOffset(x = 0, y = scrollOffset())
}
)
}For advanced compiler configuration and metrics analysis, consult the official guide on Jetpack Compose Runtime Stability and Performance Optimization.
7. Clean Architecture Layer Boundaries and Runtime Trade-offs
Clean Architecture enforces strict boundaries across the Data, Domain, and UI layers through unidirectional dependency inversion and isolated models. While mapping Data Transfer Objects (DTOs) to Domain Entities and UI State models guarantees business logic isolation, it incurs runtime object allocations and garbage collection overhead. Balancing pure domain abstraction against mapping cost is vital for performance-sensitive mobile applications handling high-throughput data.
+---------------------------------------------------------------------------------+
| UI LAYER |
| Composable Functions <---> ViewModel (Transforms Domain Models to UI State) |
+---------------------------------------------------------------------------------+
|
| Domain Models (e.g., UserProfile)
v
+---------------------------------------------------------------------------------+
| DOMAIN LAYER (Optional, pure Kotlin/Java) |
| UseCases / Interactors (Encapsulates complex business rules & orchestration) |
+---------------------------------------------------------------------------------+
|
| Repository Interfaces
v
+---------------------------------------------------------------------------------+
| DATA LAYER |
| Repository Impl (Maps DTOs/Entities to Domain) <---> Data Sources (Room, API) |
+---------------------------------------------------------------------------------+Layer Responsibilities and Model Separation
- Data Layer: Works with Data Transfer Objects (DTOs) from network APIs and Entities from local databases.
- Domain Layer: Operates on pure Kotlin business models containing no UI or framework-specific dependencies.
- UI Layer: Consumes immutable UI state models optimized for direct binding in Jetpack Compose.
// Data Layer DTO
data class ArticleDto(
@SerializedName("id") val id: String,
@SerializedName("article_title") val title: String,
@SerializedName("published_timestamp") val publishedTimestamp: Long
)
// Domain Layer Business Entity
data class Article(
val id: String,
val title: String,
val publishedDate: Instant
)
// UI Layer Representation
@Immutable
data class ArticleUiModel(
val id: String,
val title: String,
val formattedDate: String
)Architectural Trade-offs
Transforming ArticleDto $\rightarrow$ Article $\rightarrow$ ArticleUiModel maintains separation of concerns and prevents backend schema changes from leaking into the UI layer. However, in applications handling high-throughput data streams (such as real-time sensor processing or high-frequency stock tickers), continuous entity allocation can increase garbage collector (GC) frequency.
Architects must weigh the strictness of domain mapping against the performance constraints of their specific application domain. For practical enterprise implementations, refer to the Now in Android Official Architecture Reference Repository and the Android Guide to App Architecture.
8. Recommended File and Directory Structure
A well-structured Android project organizes code by feature and layer to ensure scalability, modularity, and strict separation of concerns. In modern multi-module or feature-packaged architectures, each feature module contains dedicated ui, domain, and data packages that isolate composables, ViewModels, Use Cases, and Repositories. This package layout clarifies dependency boundaries, accelerates build times, and simplifies codebase navigation for large engineering teams.
Below is an enterprise-grade directory structure demonstrating a feature-first approach with Clean Architecture boundaries:
feature-feed/
├── build.gradle.kts
└── src/
└── main/
└── java/com/example/feature/feed/
├── data/
│ ├── datasource/
│ │ ├── FeedLocalDataSource.kt
│ │ └── FeedRemoteDataSource.kt
│ ├── model/
│ │ ├── FeedItemEntity.kt
│ │ └── FeedItemResponseDto.kt
│ └── repository/
│ └── DefaultFeedRepository.kt
├── di/
│ └── FeedModule.kt
├── domain/
│ ├── model/
│ │ └── FeedItem.kt
│ ├── repository/
│ │ └── FeedRepository.kt
│ └── usecase/
│ ├── BookmarkFeedItemUseCase.kt
│ └── GetFeedUseCase.kt
└── ui/
├── component/
│ ├── FeedItemRow.kt
│ └── FeedSearchBar.kt
├── model/
│ └── FeedItemUiModel.kt
├── state/
│ ├── UserFeedUiEvent.kt
│ └── UserFeedUiState.kt
├── navigation/
│ └── FeedNavigation.kt
├── UserFeedRoute.kt
├── UserFeedScreen.kt
└── UserFeedViewModel.ktStructural Highlights
ui/: Houses all presentation logic.UserFeedRouteacts as the state-collecting coordinator (collectingStateFlowand hoisting callbacks),UserFeedScreenremains a pure, stateless composable suitable for Compose Previews, andcomponent/holds reusable leaf composables.domain/: Contains pure Kotlin interfaces, domain entities (FeedItem), and Use Cases. This layer has zero dependencies on Android framework packages (android.*).data/: Implements repository contracts defined by the domain layer, manages local/remote data sources, and performs mapping from database entities and network DTOs to domain models.di/: Contains dependency injection bindings (e.g., Hilt/Koin modules) that wire implementations to their interfaces.
9. Handling Transient One-Off Events Deterministically
Emitting transient UI events like SnackBars, navigation triggers, or dialogs through unbuffered SharedFlow risks losing events during configuration changes or inactive collection windows. Deterministic handling requires modeling one-off events either as consumable state properties inside the UI state or buffering them through a coroutine Channel. These patterns ensure that single-fire actions are reliably displayed and acknowledged without missing user feedback or duplicating executions.
SharedFlow(replay = 0) Issue:
ViewModel ---- [Emit SnackBar Event] ----> (Collector inactive during rotation) ----> Event Lost!
UI State Pattern (Deterministic):
ViewModel ---- [Update State: event != null] ----> UI renders SnackBar
UI ---- [Send onEventHandled(id)] ---------------> ViewModel clears event from StateTo ensure deterministic delivery, one-off events should either be modeled directly as transient properties within the UI State (cleared via an explicit confirmation callback) or buffered using a coroutine Channel.
Approach A: Transient Properties in UI State (Recommended)
data class MessageUiEvent(val id: Long, val message: String)
data class FeedUiState(
val items: List<FeedItemUiModel> = emptyList(),
val userMessage: MessageUiEvent? = null
)
class FeedViewModel : ViewModel() {
private val _uiState = MutableStateFlow(FeedUiState())
val uiState: StateFlow<FeedUiState> = _uiState.asStateFlow()
fun triggerAction() {
_uiState.update { it.copy(userMessage = MessageUiEvent(System.currentTimeMillis(), "Operation successful")) }
}
fun onMessageConsumed(eventId: Long) {
_uiState.update { current ->
if (current.userMessage?.id == eventId) {
current.copy(userMessage = null)
} else {
current
}
}
}
}In the Composable layer, the event is observed, acted upon, and explicitly confirmed:
@Composable
fun FeedScreen(
uiState: FeedUiState,
onMessageConsumed: (Long) -> Unit,
snackbarHostState: SnackbarHostState
) {
LaunchedEffect(uiState.userMessage) {
uiState.userMessage?.let { event ->
snackbarHostState.showSnackbar(event.message)
onMessageConsumed(event.id)
}
}
// Render remainder of the UI...
}Approach B: Buffered Channels
Alternatively, using a Channel exposed via receiveAsFlow() buffers events until a collector is active, ensuring that single-fire actions survive temporary collector disconnections:
class ChannelEventViewModel : ViewModel() {
private val _eventChannel = Channel<NavigationEvent>(Channel.BUFFERED)
val events = _eventChannel.receiveAsFlow()
fun onNavigateDetail(id: String) {
viewModelScope.launch {
_eventChannel.send(NavigationEvent.Detail(id))
}
}
}Both approaches prevent the event loss inherent to unbuffered SharedFlow collection during lifecycle transitions.
10. Frequently Asked Questions (FAQ)
This section addresses common architectural decisions and trade-offs encountered when developing modern Android applications. It clarifies key concepts around lifecycle-aware collection, StateFlow equality semantics, and Clean Architecture performance considerations. Reviewing these questions helps developers avoid frequent pitfalls when implementing reactive, declarative architectures.
Why should Android developers use collectAsStateWithLifecycle instead of collectAsState in Jetpack Compose?
Standard collectAsState() tracks only the Compose composition lifecycle, meaning the underlying coroutine collector remains active as long as the composable is part of the composition tree. If the user minimizes the application or navigates to another Activity, collectAsState() keeps the upstream Flow running, consuming CPU cycles, memory, and battery. In contrast, collectAsStateWithLifecycle() integrates with repeatOnLifecycle(Lifecycle.State.STARTED) to automatically pause upstream flow collection when the host component drops below the STARTED state and restart it when resumed.
How do StateFlow structural equality checks affect UI updates in Jetpack Compose?
StateFlow applies internal conflation by evaluating Any.equals() whenever a new value is emitted into MutableStateFlow. If the new value is structurally equal (==) to the existing StateFlow.value, the emission is skipped and downstream collectors are not notified. When working with collections or mutable objects inside state models, updating elements in place without producing a new data class instance via .copy() will prevent Compose from triggering a recomposition.
What is the performance impact of Clean Architecture entity mapping in Android?
Clean Architecture requires mapping models across the Data, Domain, and UI layers (e.g., transforming DTOs to Domain Entities, and Domain Entities to UI State models). While this boundary isolation prevents external schema changes from destabilizing UI logic and simplifies unit testing, it introduces runtime object allocations. In applications processing high-frequency data streams, these continuous transformations increase allocation rates and can lead to more frequent garbage collection pauses if not managed carefully.
About the Author
Furkan Çetinkaya is a Mobile-focused Software Developer specializing in React Native, native bridge integrations (Kotlin & Swift), and supporting backend services. Experienced in maintaining high-impact mobile applications and developer SDKs.