kotlin-coroutines-flows
affaan-m/ecc
Structured concurrency, Flow operators, and reactive patterns for Kotlin Android and KMP projects.
What is kotlin-coroutines-flows?
Master Kotlin Coroutines and Flows for async operations, reactive data streams, and proper cancellation. Use this when building concurrent Android or Kotlin Multiplatform apps that need structured scopes, Flow-based state management, and testable coroutine code.
- Implement structured concurrency with scope hierarchies (viewModelScope, coroutineScope, supervisorScope)
- Build reactive data streams using Flow, StateFlow, and SharedFlow with operators like debounce, distinctUntilChanged, and flatMapLatest
- Handle parallel decomposition with async/await and proper error handling via supervisorScope
- Test coroutines and Flows using Turbine, TestDispatcher, and fake repositories
- Manage cancellation cooperatively with ensureActive() and cleanup via try/finally blocks
- Choose correct dispatchers (Default, IO, Main) for CPU-bound, IO-bound, and UI work
How to install kotlin-coroutines-flows
npx skills add null --skill kotlin-coroutines-flows- Kotlin 1.6+ with coroutines library (kotlinx-coroutines-core)
- For Android: lifecycle-viewmodel-ktx for viewModelScope
- For testing: kotlinx-coroutines-test and Turbine library
- Basic understanding of Kotlin suspend functions and lambdas
How to use kotlin-coroutines-flows
- 1.Choose the appropriate scope (viewModelScope for UI, coroutineScope for structured children, supervisorScope for independent tasks)
- 2.Define reactive data streams using Flow, StateFlow, or SharedFlow based on your use case (one-shot, state, or events)
- 3.Apply Flow operators (debounce, distinctUntilChanged, flatMapLatest, catch, retry) to transform and handle errors
- 4.Use withContext(Dispatcher) to switch execution context for CPU-intensive or IO-bound work
- 5.Implement cooperative cancellation with ensureActive() in loops and cleanup with try/finally blocks
- 6.Write tests using runTest, TestDispatcher, Turbine, and fake repositories to verify coroutine behavior
Use cases
- Loading dashboard data in parallel from multiple repositories without blocking the UI
- Debouncing search input and fetching results with retry logic and error recovery
- Emitting one-time UI events (snackbars, navigation) via SharedFlow without replay
- Converting database observables into cold Flows and combining multiple Flows into a single StateFlow for UI state
- Testing ViewModel state transitions and Flow emissions with Turbine in unit tests
- Android developers building ViewModels and Compose UIs
- Kotlin Multiplatform (KMP) engineers managing async operations across platforms
- Backend Kotlin developers using coroutines for concurrent I/O
- QA and test engineers writing coroutine and Flow unit tests
kotlin-coroutines-flows FAQ
Use StateFlow for UI state that has a current value and replays to new subscribers. Use SharedFlow for one-time events (snackbars, navigation) that should not replay. Use Flow for cold streams that emit on demand.
GlobalScope launches coroutines without a parent scope, preventing proper cancellation and lifecycle management. This leaks coroutines and breaks structured concurrency. Always use viewModelScope, LaunchedEffect, or explicit coroutineScope instead.
Use the catch operator to handle upstream exceptions and emit a fallback value, or use retryWhen for retry logic with exponential backoff. Always propagate CancellationException — never catch it.
coroutineScope cancels all children if any child fails. supervisorScope allows children to fail independently without cancelling siblings. Use supervisorScope when syncing multiple independent data sources.
Use the Turbine library with runTest to collect emissions and assert on them. Create fake repositories that emit test data via MutableStateFlow. Use TestDispatcher to control time and advance until idle.
Full instructions (SKILL.md)
Source of truth, from affaan-m/ecc.
name: kotlin-coroutines-flows description: Kotlin Coroutines and Flow patterns for Android and KMP — structured concurrency, Flow operators, StateFlow, error handling, and testing. metadata: origin: ECC
Kotlin Coroutines & Flows
Patterns for structured concurrency, Flow-based reactive streams, and coroutine testing in Android and Kotlin Multiplatform projects.
When to Activate
- Writing async code with Kotlin coroutines
- Using Flow, StateFlow, or SharedFlow for reactive data
- Handling concurrent operations (parallel loading, debounce, retry)
- Testing coroutines and Flows
- Managing coroutine scopes and cancellation
Structured Concurrency
Scope Hierarchy
Application
└── viewModelScope (ViewModel)
└── coroutineScope { } (structured child)
├── async { } (concurrent task)
└── async { } (concurrent task)
Always use structured concurrency — never GlobalScope:
// BAD
GlobalScope.launch { fetchData() }
// GOOD — scoped to ViewModel lifecycle
viewModelScope.launch { fetchData() }
// GOOD — scoped to composable lifecycle
LaunchedEffect(key) { fetchData() }
Parallel Decomposition
Use coroutineScope + async for parallel work:
suspend fun loadDashboard(): Dashboard = coroutineScope {
val items = async { itemRepository.getRecent() }
val stats = async { statsRepository.getToday() }
val profile = async { userRepository.getCurrent() }
Dashboard(
items = items.await(),
stats = stats.await(),
profile = profile.await()
)
}
SupervisorScope
Use supervisorScope when child failures should not cancel siblings:
suspend fun syncAll() = supervisorScope {
launch { syncItems() } // failure here won't cancel syncStats
launch { syncStats() }
launch { syncSettings() }
}
Flow Patterns
Cold Flow — One-Shot to Stream Conversion
fun observeItems(): Flow<List<Item>> = flow {
// Re-emits whenever the database changes
itemDao.observeAll()
.map { entities -> entities.map { it.toDomain() } }
.collect { emit(it) }
}
StateFlow for UI State
class DashboardViewModel(
observeProgress: ObserveUserProgressUseCase
) : ViewModel() {
val progress: StateFlow<UserProgress> = observeProgress()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserProgress.EMPTY
)
}
WhileSubscribed(5_000) keeps the upstream active for 5 seconds after the last subscriber leaves — survives configuration changes without restarting.
Combining Multiple Flows
val uiState: StateFlow<HomeState> = combine(
itemRepository.observeItems(),
settingsRepository.observeTheme(),
userRepository.observeProfile()
) { items, theme, profile ->
HomeState(items = items, theme = theme, profile = profile)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeState())
Flow Operators
// Debounce search input
searchQuery
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query -> repository.search(query) }
.catch { emit(emptyList()) }
.collect { results -> _state.update { it.copy(results = results) } }
// Retry with exponential backoff
fun fetchWithRetry(): Flow<Data> = flow { emit(api.fetch()) }
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay(1000L * (1 shl attempt.toInt()))
true
} else {
false
}
}
SharedFlow for One-Time Events
class ItemListViewModel : ViewModel() {
private val _effects = MutableSharedFlow<Effect>()
val effects: SharedFlow<Effect> = _effects.asSharedFlow()
sealed interface Effect {
data class ShowSnackbar(val message: String) : Effect
data class NavigateTo(val route: String) : Effect
}
private fun deleteItem(id: String) {
viewModelScope.launch {
repository.delete(id)
_effects.emit(Effect.ShowSnackbar("Item deleted"))
}
}
}
// Collect in Composable
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is Effect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message)
is Effect.NavigateTo -> navController.navigate(effect.route)
}
}
}
Dispatchers
// CPU-intensive work
withContext(Dispatchers.Default) { parseJson(largePayload) }
// IO-bound work
withContext(Dispatchers.IO) { database.query() }
// Main thread (UI) — default in viewModelScope
withContext(Dispatchers.Main) { updateUi() }
In KMP, use Dispatchers.Default and Dispatchers.Main (available on all platforms). Dispatchers.IO is JVM/Android only — use Dispatchers.Default on other platforms or provide via DI.
Cancellation
Cooperative Cancellation
Long-running loops must check for cancellation:
suspend fun processItems(items: List<Item>) = coroutineScope {
for (item in items) {
ensureActive() // throws CancellationException if cancelled
process(item)
}
}
Cleanup with try/finally
viewModelScope.launch {
try {
_state.update { it.copy(isLoading = true) }
val data = repository.fetch()
_state.update { it.copy(data = data) }
} finally {
_state.update { it.copy(isLoading = false) } // always runs, even on cancellation
}
}
Testing
Testing StateFlow with Turbine
@Test
fun `search updates item list`() = runTest {
val fakeRepository = FakeItemRepository().apply { emit(testItems) }
val viewModel = ItemListViewModel(GetItemsUseCase(fakeRepository))
viewModel.state.test {
assertEquals(ItemListState(), awaitItem()) // initial
viewModel.onSearch("query")
val loading = awaitItem()
assertTrue(loading.isLoading)
val loaded = awaitItem()
assertFalse(loaded.isLoading)
assertEquals(1, loaded.items.size)
}
}
Testing with TestDispatcher
@Test
fun `parallel load completes correctly`() = runTest {
val viewModel = DashboardViewModel(
itemRepo = FakeItemRepo(),
statsRepo = FakeStatsRepo()
)
viewModel.load()
advanceUntilIdle()
val state = viewModel.state.value
assertNotNull(state.items)
assertNotNull(state.stats)
}
Faking Flows
class FakeItemRepository : ItemRepository {
private val _items = MutableStateFlow<List<Item>>(emptyList())
override fun observeItems(): Flow<List<Item>> = _items
fun emit(items: List<Item>) { _items.value = items }
override suspend fun getItemsByCategory(category: String): Result<List<Item>> {
return Result.success(_items.value.filter { it.category == category })
}
}
Anti-Patterns to Avoid
- Using
GlobalScope— leaks coroutines, no structured cancellation - Collecting Flows in
init {}without a scope — useviewModelScope.launch - Using
MutableStateFlowwith mutable collections — always use immutable copies:_state.update { it.copy(list = it.list + newItem) } - Catching
CancellationException— let it propagate for proper cancellation - Using
flowOn(Dispatchers.Main)to collect — collection dispatcher is the caller's dispatcher - Creating
Flowin@Composablewithoutremember— recreates the flow every recomposition
References
See skill: compose-multiplatform-patterns for UI consumption of Flows.
See skill: android-clean-architecture for where coroutines fit in layers.
Related skills
More from affaan-m/ecc and the wider catalog.
kotlin-exposed-patterns
JetBrains Exposed ORM patterns with DSL queries, DAO, transactions, HikariCP pooling, and Flyway migrations.
kotlin-ktor-patterns
Ktor server patterns for routing, authentication, DI, serialization, WebSockets, and testing.
kotlin-patterns
Idiomatic Kotlin patterns, best practices, and conventions for robust, maintainable applications.
kotlin-testing
Kotlin testing patterns with Kotest, MockK, coroutine testing, property-based testing, and Kover coverage.
kubernetes-patterns
Agent skill from affaan-m/ecc.
laravel-patterns
Production-grade Laravel architecture patterns for scalable, maintainable applications.