Building Payment Flows in Android: Production Patterns from Fintech Applications
Subtitle: Architectural patterns and state management strategies for reliable payment flows at scale
Introduction
Over the past months, I've been building and refining payment systems at scale. Every architectural decision compounds over time, and payment flows demand particular attention to state management, error handling, and offline resilience.
This article documents the patterns that have proven effective in production fintech applications. These aren't theoretical approaches—they represent lessons learned from handling millions of transactions and the edge cases that accompany them.
Why Payment Flows Require Specialized Architecture
Most mobile applications tolerate a certain degree of architectural looseness. A user can restart the app if something fails. Recovery is straightforward.
Payment flows operate under different constraints:
State Ambiguity is Costly
When a user initiates a payment, your application must maintain absolute clarity about its status: submitted, pending, failed, queued offline. If this state is unclear, the user is equally uncertain, leading to duplicate submissions or lost transactions.
Race Conditions Have Financial Consequences
A user confirms payment and immediately navigates away. Your retry logic must account for this without resubmitting the payment or losing the transaction record.
Network Reliability Cannot Be Assumed
In many markets, network interruptions occur mid-transaction. Your system must handle incomplete requests gracefully—queuing the transaction locally and retrying when connectivity returns.
Offline-First Is Essential
Users may initiate transactions without active connectivity. The application must queue payments locally, survive process termination, and submit reliably when network becomes available.
This is why Clean Architecture and MVI pattern implementation is not optional for payment flows—it becomes a structural guarantee that state remains traceable and predictable.
Architectural Foundation
For context on the underlying architecture, see Clean Architecture in Android: A Real-World Guide.
The structure we use:
- :domain — Pure Kotlin business logic without Android dependencies
- :data — Repository layer managing offline queues, network calls, and persistence
- :feature:payment — MVI implementation containing Intent, UiState, ViewModel, and UI layers
This separation ensures business logic can be tested independently of Android runtime, catching state-related bugs before deployment.
Pattern 1: Payment Confirmation Flow with Explicit State
The MVI Contract
sealed class PaymentConfirmationIntent {
data class LoadPaymentDetails(val paymentId: String) : PaymentConfirmationIntent()
object ConfirmPayment : PaymentConfirmationIntent()
object RetryPayment : PaymentConfirmationIntent()
object CancelPayment : PaymentConfirmationIntent()
object DismissError : PaymentConfirmationIntent()
}
data class PaymentConfirmationUiState(
val paymentDetails: PaymentDetails? = null,
val isLoading: Boolean = false,
val isConfirming: Boolean = false,
val error: PaymentError? = null,
val isOffline: Boolean = false,
val confirmationStatus: ConfirmationStatus = ConfirmationStatus.Idle,
) {
enum class ConfirmationStatus {
Idle,
AwaitingUserConfirmation,
Processing,
Success,
Failed,
OfflineQueued,
}
}
sealed class PaymentConfirmationEffect {
object NavigateToSuccess : PaymentConfirmationEffect()
object NavigateToFailure : PaymentConfirmationEffect()
object NavigateBack : PaymentConfirmationEffect()
}
Structural Benefits:
The ConfirmationStatus.OfflineQueued state explicitly represents the condition where payment has been submitted locally and awaits network availability. This eliminates ambiguity about whether a transaction was successfully queued.
Each Intent represents a discrete user action with no overlap, preventing state collisions. The isOffline flag as part of UiState ensures the UI layer can respond to network conditions with appropriate messaging.
ViewModel Implementation
@HiltViewModel
class PaymentConfirmationViewModel @Inject constructor(
private val paymentRepository: PaymentRepository,
private val offlinePaymentQueue: OfflinePaymentQueue,
private val networkManager: NetworkManager,
) : ViewModel() {
private val _state = MutableStateFlow<PaymentConfirmationUiState>(
PaymentConfirmationUiState()
)
val state: StateFlow<PaymentConfirmationUiState> = _state.asStateFlow()
fun processIntent(intent: PaymentConfirmationIntent) {
when (intent) {
is PaymentConfirmationIntent.LoadPaymentDetails ->
loadPaymentDetails(intent.paymentId)
is PaymentConfirmationIntent.ConfirmPayment ->
confirmPayment()
is PaymentConfirmationIntent.RetryPayment ->
retryPayment()
is PaymentConfirmationIntent.CancelPayment ->
cancelPayment()
is PaymentConfirmationIntent.DismissError ->
dismissError()
}
}
private fun confirmPayment() {
viewModelScope.launch {
_state.update { it.copy(isConfirming = true) }
try {
if (!networkManager.isConnected()) {
val queuedPayment = offlinePaymentQueue.enqueue(
_state.value.paymentDetails ?: return@launch
)
_state.update {
it.copy(
isConfirming = false,
confirmationStatus =
PaymentConfirmationUiState.ConfirmationStatus.OfflineQueued,
)
}
return@launch
}
val result = paymentRepository.submitPayment(
_state.value.paymentDetails!!
)
when (result) {
is PaymentResult.Success -> {
_state.update {
it.copy(
isConfirming = false,
confirmationStatus =
PaymentConfirmationUiState.ConfirmationStatus.Success,
)
}
}
is PaymentResult.Failure -> {
_state.update {
it.copy(
isConfirming = false,
error = result.error,
confirmationStatus =
PaymentConfirmationUiState.ConfirmationStatus.Failed,
)
}
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isConfirming = false,
error = PaymentError.Unknown(e.message ?: "Unknown error"),
confirmationStatus =
PaymentConfirmationUiState.ConfirmationStatus.Failed,
)
}
}
}
}
private fun retryPayment() {
confirmPayment()
}
private fun dismissError() {
_state.update { it.copy(error = null) }
}
private fun loadPaymentDetails(paymentId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val details = paymentRepository.getPaymentDetails(paymentId)
_state.update {
it.copy(
isLoading = false,
paymentDetails = details,
isOffline = !networkManager.isConnected(),
)
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
error = PaymentError.LoadFailed(
e.message ?: "Failed to load"
),
)
}
}
}
}
private fun cancelPayment() {
// Navigate back without submission
}
}
Key Implementation Details:
- Network connectivity is checked before submission
- Offline state triggers local queueing with explicit status update
- Exception handling updates UI state rather than propagating errors
- All state transitions are explicit and traceable
UI Layer
@Composable
fun PaymentConfirmationScreen(
viewModel: PaymentConfirmationViewModel,
paymentId: String,
onSuccess: () -> Unit,
onFailure: () -> Unit,
onBack: () -> Unit,
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.processIntent(
PaymentConfirmationIntent.LoadPaymentDetails(paymentId)
)
}
LaunchedEffect(state.confirmationStatus) {
when (state.confirmationStatus) {
PaymentConfirmationUiState.ConfirmationStatus.Success ->
onSuccess()
PaymentConfirmationUiState.ConfirmationStatus.Failed ->
onFailure()
else -> {}
}
}
Box(modifier = Modifier.fillMaxSize()) {
when {
state.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
state.paymentDetails != null -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PaymentDetailsCard(state.paymentDetails)
if (state.isOffline) {
OfflineWarningBanner()
}
state.error?.let { error ->
ErrorBanner(
message = error.message,
onDismiss = {
viewModel.processIntent(
PaymentConfirmationIntent.DismissError
)
}
)
}
Spacer(modifier = Modifier.weight(1f))
when (state.confirmationStatus) {
PaymentConfirmationUiState.ConfirmationStatus
.AwaitingUserConfirmation -> {
ConfirmationButtons(viewModel, onBack)
}
PaymentConfirmationUiState.ConfirmationStatus
.OfflineQueued -> {
Button(
onClick = { onBack() },
modifier = Modifier.fillMaxWidth(),
) {
Text("Done")
}
}
PaymentConfirmationUiState.ConfirmationStatus.Failed -> {
Button(
onClick = {
viewModel.processIntent(
PaymentConfirmationIntent.RetryPayment
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Retry")
}
}
else -> {}
}
}
}
state.error != null -> {
ErrorScreen(
error = state.error,
onRetry = {
viewModel.processIntent(
PaymentConfirmationIntent
.LoadPaymentDetails(paymentId)
)
},
onBack = onBack,
)
}
}
}
}
@Composable
private fun OfflineWarningBanner() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFFF3CD)
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = Color(0xFFFF9800),
)
Text(
text = "You are offline. Your payment will be submitted when connectivity is restored.",
fontSize = 14.sp,
modifier = Modifier.weight(1f),
)
}
}
}
Pattern 2: Offline Payment Queue with Persistence
Local queueing with durable storage is essential for offline-first applications.
// domain/payment/OfflinePaymentQueue.kt
data class QueuedPayment(
val id: String,
val payment: PaymentDetails,
val timestamp: Long,
val retryCount: Int = 0,
val status: QueueStatus = QueueStatus.Pending,
) {
enum class QueueStatus {
Pending,
Retrying,
Submitted,
Failed,
}
}
interface OfflinePaymentQueue {
suspend fun enqueue(payment: PaymentDetails): QueuedPayment
suspend fun getAll(): List<QueuedPayment>
suspend fun getById(id: String): QueuedPayment?
suspend fun remove(id: String)
suspend fun updateStatus(id: String, status: QueueStatus)
suspend fun incrementRetry(id: String)
}
// data/payment/OfflinePaymentQueueImpl.kt
@Singleton
class OfflinePaymentQueueImpl @Inject constructor(
private val paymentDao: PaymentQueueDao,
) : OfflinePaymentQueue {
override suspend fun enqueue(payment: PaymentDetails): QueuedPayment {
val queued = QueuedPayment(
id = UUID.randomUUID().toString(),
payment = payment,
timestamp = System.currentTimeMillis(),
)
paymentDao.insert(queued.toEntity())
return queued
}
override suspend fun getAll(): List<QueuedPayment> {
return paymentDao.getAll().map { it.toDomain() }
}
override suspend fun updateStatus(
id: String,
status: QueueStatus
) {
paymentDao.updateStatus(id, status.name)
}
override suspend fun remove(id: String) {
paymentDao.delete(id)
}
override suspend fun incrementRetry(id: String) {
paymentDao.incrementRetry(id)
}
override suspend fun getById(id: String): QueuedPayment? {
return paymentDao.getById(id)?.toDomain()
}
}
Background synchronization handles submission when connectivity is restored:
// data/payment/PaymentSyncWorker.kt
class PaymentSyncWorker @Inject constructor(
context: Context,
params: WorkerParameters,
private val offlineQueue: OfflinePaymentQueue,
private val paymentRepository: PaymentRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val queuedPayments = offlineQueue.getAll()
.filter { it.status == QueueStatus.Pending }
queuedPayments.forEach { queued ->
try {
offlineQueue.updateStatus(
queued.id,
QueueStatus.Retrying
)
val result = paymentRepository.submitPayment(
queued.payment
)
when (result) {
is PaymentResult.Success -> {
offlineQueue.updateStatus(
queued.id,
QueueStatus.Submitted
)
offlineQueue.remove(queued.id)
}
is PaymentResult.Failure -> {
if (queued.retryCount < MAX_RETRIES) {
offlineQueue.incrementRetry(queued.id)
} else {
offlineQueue.updateStatus(
queued.id,
QueueStatus.Failed
)
}
}
}
} catch (e: Exception) {
if (queued.retryCount < MAX_RETRIES) {
offlineQueue.incrementRetry(queued.id)
} else {
offlineQueue.updateStatus(
queued.id,
QueueStatus.Failed
)
}
}
}
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
companion object {
private const val MAX_RETRIES = 5
const val WORK_NAME = "payment_sync"
}
}
// Schedule in application startup:
// WorkManager.getInstance(context).enqueueUniquePeriodicWork(
// PaymentSyncWorker.WORK_NAME,
// ExistingPeriodicWorkPolicy.KEEP,
// PeriodicWorkRequestBuilder<PaymentSyncWorker>(
// 15, TimeUnit.MINUTES
// ).build()
// )
Benefits of This Approach:
- Payment data persists across process termination
- Retry logic respects maximum attempt limits
- Each payment has a unique identifier preventing duplicate submissions
- Status transitions are durable and traceable
Pattern 3: Biometric Authentication for Payment Confirmation
Biometric authentication integrates as a discrete state within the payment flow:
sealed class BiometricAuthIntent {
object AuthenticateWithBiometric : BiometricAuthIntent()
object CancelBiometric : BiometricAuthIntent()
object RetryBiometric : BiometricAuthIntent()
}
data class BiometricAuthUiState(
val isAuthenticating: Boolean = false,
val authResult: BiometricAuthResult? = null,
val error: BiometricError? = null,
) {
sealed class BiometricAuthResult {
object Success : BiometricAuthResult()
object Failure : BiometricAuthResult()
object Cancelled : BiometricAuthResult()
}
}
@HiltViewModel
class BiometricAuthViewModel @Inject constructor(
private val biometricManager: BiometricManager,
) : ViewModel() {
private val _state = MutableStateFlow<BiometricAuthUiState>(
BiometricAuthUiState()
)
val state: StateFlow<BiometricAuthUiState> = _state.asStateFlow()
fun processIntent(intent: BiometricAuthIntent) {
when (intent) {
BiometricAuthIntent.AuthenticateWithBiometric -> authenticate()
BiometricAuthIntent.CancelBiometric -> cancel()
BiometricAuthIntent.RetryBiometric -> authenticate()
}
}
private fun authenticate() {
viewModelScope.launch {
_state.update { it.copy(isAuthenticating = true) }
try {
val result = biometricManager.authenticate()
_state.update {
it.copy(
isAuthenticating = false,
authResult = result,
)
}
} catch (e: BiometricException) {
_state.update {
it.copy(
isAuthenticating = false,
error = BiometricError.AuthenticationFailed(
e.message ?: "Authentication failed"
),
)
}
}
}
}
private fun cancel() {
biometricManager.cancel()
_state.update {
it.copy(
isAuthenticating = false,
authResult = BiometricAuthUiState.BiometricAuthResult
.Cancelled,
)
}
}
}
Biometric authentication state remains consistent with the MVI pattern rather than introducing separate concerns.
Transaction Flow Example
Consider a complete transaction lifecycle:
- User initiates payment confirmation
- App checks network connectivity
- If offline: payment queued locally with
OfflineQueuedstatus - If online: payment submitted to server
- On network restoration: background worker processes offline queue
- Each queued payment retried up to 5 times before marking failed
- User notified of final status through UI state updates
This approach ensures:
- No state ambiguity at any point
- Network interruptions don't result in lost transactions
- Duplicate submissions are prevented
- Process termination doesn't destroy payment records
Open-Source Implementation
These patterns are available in production-ready form:
clean-architecture-android — Foundation template with Clean Architecture, MVI, and Jetpack Compose
fintech-mobile-patterns-android — Payment flow implementations with offline queuing, error handling, and authentication patterns
Both repositories include complete implementations with testing examples.
Conclusion
Payment flow architecture demands explicit state management and thoughtful error handling. The patterns discussed—explicit state enums, offline queuing with persistence, and MVI-based architecture—provide structural guarantees that reduce the likelihood of state-related bugs.
These approaches have proven effective at production scale. They prioritize clarity of state over brevity of code, understanding that payment systems benefit more from predictability than elegance.
Top comments (0)