How We Use Kotlin Coroutines & Flow in Enterprise Android
In this article, I'll show how we use Coroutines + Flow in production inside a digital wealth management app. This is not theoretical coroutine usage.
This is orchestration for:
- Portfolio summary, Investment accounts
- Risk profile validation, Regulatory document loading
- Sensitive balance visibility toggle
- Parallel dashboard aggregation
- Child dependent API triggers
- Compose-driven UI state
- Backpressure handling
- Error handling patterns
- Token refresh flows
- Retry policies
- App preloading optimization
Architecture Context
- Clean Architecture / MVVM / MVI
- Repository pattern
- UseCase returns Flow
- ViewModel orchestrates flows
- Immutable UI state via StateFlow
- Compose collects state
- Retrofit + Room
- No GlobalScope
- No blocking calls
1. Sequential Orchestration (Profile → Portfolio)
Real Scenario
When user opens Portfolio screen:
- Load investor profile
- Update greeting header
- Fetch portfolio accounts
- Map into UI state
- Handle stage-specific errors
ViewModel Implementation
private fun loadInvestorOverview() {
getInvestorProfileUseCase
.execute(Unit)
.onStart { setLoading(true) }
.catch { error ->
setLoading(false)
showInvestorError(error)
}
.flatMapConcat { profile ->
_uiState.update {
it.copy(
investorName = "${profile.firstName} ${profile.lastName}"
)
}
getPortfolioAccountsUseCase.execute(profile.investorId)
}
.catch { error ->
setLoading(false)
handlePortfolioError(error)
}
.onEach { accounts ->
_uiState.update {
it.copy(
portfolioAccounts = accounts.map {
PortfolioItem(
accountId = it.accountId,
productType = it.productType
)
}
)
}
}
.onCompletion { setLoading(false) }
.launchIn(viewModelScope)
}
Why This Pattern Is Important
- First error handles profile domain
- Second error handles portfolio domain
- No nested coroutine blocks
- Pipeline remains flat and readable
- Cancellation remains structured
This is how real fintech orchestration should look.
2. Parallel Dashboard Aggregation (combine)
Scenario
On dashboard load:
- Fetch investment accounts
- Fetch total asset value Show screen only when both available
fun initializeDashboard() {
val accountsFlow = getInvestmentAccountsUseCase.execute(Unit)
val assetValueFlow = getTotalAssetValueUseCase.execute(Unit)
accountsFlow
.combine(assetValueFlow) { accounts, totalValue ->
accounts to totalValue
}
.onStart { setLoading(true) }
.onCompletion { setLoading(false) }
.catch { showDashboardError(it) }
.onEach { (accounts, totalValue) ->
_uiState.update {
it.copy(
investmentAccounts = accounts,
totalAssets = totalValue
)
}
}
.launchIn(viewModelScope)
}
Why combine instead of zip?
combine reacts when either emits.
Asset value might refresh independently of account list.
In real apps:
- Accounts rarely change
- Market value changes frequently
combine supports this naturally.
3. Strict Regulatory Pairing (zip)
Scenario
Before showing Trade screen:
- Load risk disclosure template
- Load risk summary view template
Both must succeed.
private fun loadRiskDocuments() {
getDisclosureTemplateUseCase.execute(DISCLOSURE_FULL)
.zip(
getDisclosureTemplateUseCase.execute(DISCLOSURE_SUMMARY)
) { full, summary ->
full to summary
}
.onStart { setLoading(true) }
.onCompletion { setLoading(false) }
.catch { showDocumentError(it) }
.onEach { (fullDoc, summaryDoc) ->
_uiState.update {
it.copy(
fullDisclosure = fullDoc,
summaryDisclosure = summaryDoc
)
}
}
.launchIn(viewModelScope)
}
Why zip here?
Because both documents are mandatory before proceeding.
No partial rendering allowed.
4. Detail Loading + Dependent Call
Scenario
When user selects an investment account:
- Load account details
- Update state
- Trigger transaction history call
fun loadAccountDetail(accountId: String, refresh: Boolean = false) {
getAccountDetailUseCase
.execute(accountId)
.onStart { setLoading(true) }
.onCompletion {
_uiState.update { it.copy(isRefreshing = false) }
}
.catch { handleDetailError(it) }
.onEach { detail ->
_uiState.update {
it.copy(
selectedAccount = detail,
hasDividendOption = detail.dividendOptions.isNotEmpty()
)
}
loadTransactionHistory(accountId, refresh)
}
.launchIn(viewModelScope)
}
Why trigger child call inside onEach?
Because:
- Only executed after success
- No nested coroutineScope
- Keeps orchestration centralized in ViewModel
5. UI State for Compose (Immutable)
data class PortfolioUiState(
val isLoading: Boolean = false,
val investorName: String = "",
val investmentAccounts: List<PortfolioItem> = emptyList(),
val totalAssets: AssetValue? = null,
val selectedAccount: AccountDetail? = null,
val hasDividendOption: Boolean = false,
val fullDisclosure: Document? = null,
val summaryDisclosure: Document? = null
)
ViewModel State Holder
private val _uiState = MutableStateFlow(PortfolioUiState())
val uiState: StateFlow<PortfolioUiState> = _uiState
6. Compose UI (Reactive +Â Clean)
@Composable
fun PortfolioScreen(viewModel: PortfolioViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
if (state.isLoading) {
CircularProgressIndicator()
}
Text(text = "Welcome ${state.investorName}")
state.totalAssets?.let {
Text("Total Assets: ${it.formatted}")
}
LazyColumn {
items(state.investmentAccounts) { account ->
Text(account.accountId)
}
}
}
No LiveData and No manual observers.
Pure Flow ----> StateFlow ----> Compose.
7. Reactive Sensitive Value Visibility (Flow-Based)
Instead of Rx sensor logic, we use Flow:
Scenario:
- Hide portfolio value when device is face down
- Show when user touches screen
val sensitiveVisibilityFlow =
combine(deviceOrientationFlow, userInteractionFlow) { isFaceDown, isTouching ->
!isFaceDown && isTouching
}.distinctUntilChanged()
In ViewModel:
sensitiveVisibilityFlow
.onEach { visible ->
_uiState.update {
it.copy(isPortfolioVisible = visible)
}
}
.launchIn(viewModelScope)
Compose:
AnimatedVisibility(visible = state.isPortfolioVisible) {
PortfolioValueSection()
}
8. Handling Inactivity (PIN / Login Expire)
Use a shared flow for session events.
object SessionManager {
private val _sessionExpired = MutableSharedFlow<Unit>()
val sessionExpired = _sessionExpired.asSharedFlow()
suspend fun expire() {
_sessionExpired.emit(Unit)
}
}
In BaseViewModel:
viewModelScope.launch {
SessionManager.sessionExpired.collect {
navigator.navigateToLogin()
}
}
9. Prevent Multiple Button Clicks (Throttle)
Double execution is not a fintech issue.
It's a concurrency failure - and every serious app must prevent it.
Throttle First
fun <T> Flow<T>.throttleFirst(windowDuration: Long): Flow<T> = flow {
var lastTime = 0L
collect { value ->
val current = System.currentTimeMillis()
if (current - lastTime >= windowDuration) {
lastTime = current
emit(value)
}
}
}
Usage:
buttonClicks
.throttleFirst(1000)
.onEach { viewModel.sendMoney() }
.launchIn(scope)
10. Backpressure Handling (High Frequency Events)
Example:
- Search field
- Real-time stock ticker
Debounce + FlatMapLatest
searchQuery
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query ->
repository.searchStocks(query)
}
flatMapLatest cancels previous request.
11. Avoid Blocking User Experience
Never:
runBlocking { }
Thread.sleep()
Instead:
withContext(Dispatchers.IO) {
heavyWork()
}
Or better - push heavy work to repository layer.
12. Jetpack Compose Concurrency Patterns
Collect State Safely
val balance by viewModel.balanceFlow.collectAsStateWithLifecycle()
Launch Side Effects
LaunchedEffect(Unit) {
viewModel.loadDashboard()
}
Snackbar Error Flow
LaunchedEffect(viewModel.errorFlow) {
viewModel.errorFlow.collect {
snackbarHostState.showSnackbar(it.message)
}
}
13. Advanced Retry with Exponential Backoff
In financial systems:
- Network instability is common
- Backend throttling happens
- Temporary 5xx failures occur
- You must retry safely - but not aggressively
Retry is not retry(3).
Retry must be:
- Conditional
- Intelligent
- Exponential
- Cancellable
- Token-aware
13.1 Production Pattern: Conditional Exponential Backoff
Scenario
Refreshing portfolio valuation from market service.
private fun refreshMarketValuation() {
getMarketValuationUseCase
.execute(Unit)
.retryWhen { cause, attempt ->
val isNetworkError = cause is IOException
val isServerError = cause is HttpException && cause.code() >= 500
if ((isNetworkError || isServerError) && attempt < 3) {
val backoffDelay = 1_000L * (2.0.pow(attempt.toDouble())).toLong()
delay(backoffDelay)
true
} else {
false
}
}
.onStart { setLoading(true) }
.onCompletion { setLoading(false) }
.catch { handleMarketError(it) }
.onEach { valuation ->
_uiState.update { it.copy(marketValuation = valuation) }
}
.launchIn(viewModelScope)
}
What Happens Here?
- Retries only network + 5xx
- Does NOT retry business errors (4xx)
- Exponential backoff: 1s → 2s → 4s
- Automatically cancelled if ViewModel cleared
- No blocking threads
This prevents backend abuse while improving reliability.
14. Token Expiry + Automatic Refresh
In fintech/investment systems:
- Access tokens expire frequently
- Multiple API calls may fail simultaneously
- Only ONE refresh call must execute
- Other calls must wait
If you don't orchestrate this properly:
- You trigger multiple refresh requests
- Backend invalidates sessions
- Users get forced logout
Core Rule
Token refresh must be:
- Centralized
- Mutex-protected
- Reusable across flows
Token Coordinator
class AuthTokenCoordinator(
private val refreshTokenUseCase: RefreshTokenUseCase,
private val tokenStorage: TokenStorage
) {
private val mutex = Mutex()
suspend fun refreshIfNeeded(): String {
return mutex.withLock {
val newToken = refreshTokenUseCase.execute(Unit).first()
tokenStorage.save(newToken)
newToken.accessToken
}
}
}
Why Mutex?
If 5 APIs fail with 401 at the same time:
- Without Mutex → 5 refresh calls
- With Mutex → 1 refresh call
- All suspended callers wait safely.
14.1 Token Refresh Orchestration Pattern (Flow Level)
Now the important part:
- How do we retry original API after token refresh?
We do NOT handle token inside every ViewModel.
We handle it at repository layer.
Repository-Level Flow Orchestration
Scenario
Fetching portfolio summary.
fun getPortfolioSummary(): Flow<PortfolioSummary> {
return flow {
emit(api.getPortfolioSummary())
}
.catch { throwable ->
if (throwable is HttpException && throwable.code() == 401) {
val newToken = authTokenCoordinator.refreshIfNeeded()
emit(api.getPortfolioSummaryWithToken(newToken))
} else {
throw throwable
}
}
}
What Happens Here?
- API call fails with 401
- Refresh token (mutex protected)
- Retry original call
- Emit result
- Upstream ViewModel never knows refresh happened
This keeps ViewModel clean.
14.2 Even Cleaner: Reusable Token Wrapper
For large systems, create a reusable wrapper:
fun <T> Flow<T>.withTokenRefresh(
authTokenCoordinator: AuthTokenCoordinator,
retryBlock: suspend (String) -> T
): Flow<T> {
return catch { throwable ->
if (throwable is HttpException && throwable.code() == 401) {
val newToken = authTokenCoordinator.refreshIfNeeded()
emit(retryBlock(newToken))
} else {
throw throwable
}
}
}
Usage:
fun getPortfolioSummary(): Flow<PortfolioSummary> {
return flow {
emit(api.getPortfolioSummary())
}.withTokenRefresh(authTokenCoordinator) { newToken ->
api.getPortfolioSummaryWithToken(newToken)
}
}
Why This Pattern Scales
- Centralized
- Reusable
- No duplication
- Mutex-safe
- ViewModel unaware of auth complexity
- Works with combine / zip / flatMap
14.3 What Happens with Parallel Calls + Token Expiry?
Let's say:
initializeDashboard()
→ getInvestmentAccounts()
→ getTotalAssetValue()
Both fail with 401.
What happens?
- First failure triggers refresh
- Second waits on mutex
- Both resume using new token
- combine still works
- UI sees only final result
About Auth Orchestration
In this kind of app, Token management is not an interceptor problem only. It is a concurrency orchestration problem.
App must be design with these:
- Mutex protection
- Repository-level retry
- ViewModel isolation
- Cancellation safety
- Backoff compatibility
When done correctly:
- UI remains clean
- Flows remain declarative
- Orchestration remains centralized
- System remains resilient
In fintech apps, concurrency is not optimization.
Final Thought
Coroutines and Flow are not async tools.
They are:
- Orchestration engine
- Error propagation model
- State machine builder
- Backpressure handler
- Lifecycle-aware execution framework
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.