Combining Flows Into UI Ready Models in the Domain Layer
Combining Flows Into UI Ready Models in the Domain Layer
Most Android apps eventually reach the same shape. There is a list of things loaded from a network or database, and there is a set of user choices about those things stored somewhere else. A screen that shows topics also needs to know which topics the user follows. A screen that shows articles also needs to know which ones are bookmarked. You already know the tool for merging two reactive sources into one: combine. The deeper question is where that merge should live and what it should produce. Google's Now in Android sample answers it with a small domain layer use case that folds user preferences into raw data and hands the UI a stream that already knows what is followed.
In this article, you'll dive deep into how a domain layer use case composes two independent flows into one UI ready stream, exploring the Room backed topics flow and the DataStore backed preferences flow that feed it, the combine operator that joins them, the decoration step that folds preferences into each item, the cold to hot transition each ViewModel applies with stateIn, the search variant that reuses the same shape, and the honest inconsistency of where this composition actually lives across the codebase.
The fundamental problem: reactive composition duplicated across ViewModels
Picture the Interests screen. It shows every topic and marks the ones the user follows. The most direct implementation injects both repositories into the ViewModel and merges them inline:
class InterestsViewModel @Inject constructor(
topicsRepository: TopicsRepository,
userDataRepository: UserDataRepository,
) : ViewModel() {
val uiState = combine(
topicsRepository.getTopics(),
userDataRepository.userData,
) { topics, userData ->
topics.map { topic ->
FollowableTopic(topic, isFollowed = topic.id in userData.followedTopics)
}
}
}
This works, and for a single screen it is reasonable. The problem appears when more screens need the same data. In Now in Android, the Interests list and the For You onboarding selector both want topics paired with their followed state, and the Search results want the same decoration folded into a different shape. If every ViewModel writes its own combine and its own decoration loop, the same logic is copied across the UI layer. The day you add a sort option, or the day the decoration needs a second preference field, you edit every copy and hope you found them all. The composition is a piece of domain behavior, yet it is scattered across the UI layer.
The fix is to name that behavior and give it one home. Before looking at the use case that does this, you need to understand the two streams it consumes, because their properties are what make the composition behave the way it does.
The two upstream flows: raw data and user preferences
The composition has exactly two inputs. One carries the raw catalog of topics from the local database. The other carries the user's choices from a preferences store. Both are cold Flows that re-emit whenever their backing storage changes, and that reactive property is what makes the whole pattern live.
Topics from Room
The raw topics originate in a Room database. Room exposes a query result as a Flow, which means it emits the current rows on collection and then re-emits a fresh list every time the underlying table changes. The DAO declares exactly that:
@Dao
interface TopicDao {
@Query(value = "SELECT * FROM topics")
fun getTopicEntities(): Flow<List<TopicEntity>>
}
getTopicEntities() returns Flow<List<TopicEntity>>. You never call it twice to refresh. Room observes the topics table and pushes a new list into the flow on any insert, update, or delete. The entities it emits are database rows, not domain models, so the repository maps them one layer up:
internal class OfflineFirstTopicsRepository @Inject constructor(
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> =
topicDao.getTopicEntities()
.map { it.map(TopicEntity::asExternalModel) }
}
The name says offline first, and the read path proves it: getTopics() reads exclusively from topicDao. The network dependency is only touched during a separate sync, where it writes fresh data into Room. The combined stream you build later always reflects the local database, never a direct network read. The consuming use case does not depend on this class though. It depends on the interface:
interface TopicsRepository : Syncable {
fun getTopics(): Flow<List<Topic>>
fun getTopic(id: String): Flow<Topic>
}
Depending on TopicsRepository rather than OfflineFirstTopicsRepository keeps the domain layer unaware of Room. Swap in a fake that returns a fixed list and the use case behaves identically, which is what makes it testable in isolation.
Preferences from DataStore
The second stream carries user choices, and it lives in a Proto DataStore rather than Room. The data source reads the proto and maps it into a domain model whose fields are plain Kotlin Sets:
class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>,
) {
val userData = userPreferences.data
.map {
UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys,
viewedNewsResources = it.viewedNewsResourceIdsMap.keys,
followedTopics = it.followedTopicIdsMap.keys,
// theme, dark config, dynamic color, and onboarding fields omitted
)
}
}
The proto stores each preference as a map, and the mapping keeps only the keys, turning followedTopicIdsMap into a Set<String> of followed topic ids. Like Room, DataStore.data is a flow that emits the current value on collection and re-emits after every write. The repository forwards this stream unchanged and adds the write side:
internal class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val analyticsHelper: AnalyticsHelper,
) : UserDataRepository {
override val userData: Flow<UserData> = niaPreferencesDataSource.userData
override suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean) {
niaPreferencesDataSource.setTopicIdFollowed(followedTopicId, followed)
analyticsHelper.logTopicFollowToggled(followedTopicId, followed)
}
}
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor