Android Architecture Guidelines
Scalable, testable, and maintainable Android architecture patterns for building robust applications.
Overview
This guide covers Android application architecture following Clean Architecture principles with Android-specific implementations using Kotlin, Jetpack Compose, Hilt, Room, and Kotlin Flow. For foundational Clean Architecture concepts, the dependency rule, and layer responsibilities, see Architecture Overview.
Android Clean Architecture applies these principles through specific technologies: the domain layer uses pure Kotlin data classes and coroutines for business logic; the data layer implements repositories with Room for local caching and Retrofit for network communication; and the presentation layer uses Jetpack Compose with ViewModels managing StateFlow for reactive UI state. This guide focuses on Android-specific patterns including Hilt dependency injection configuration, MVVM/MVI state management with Compose, and Kotlin Flow integration across layers.
For detailed implementation guidance, see Android Data & Networking for Room and Retrofit patterns and Android UI for Compose integration.
Core Principles
For general Clean Architecture principles (separation of concerns, dependency rule, single source of truth), see Architecture Overview. The following are Android-specific architectural principles:
- Kotlin Flow for Reactive Data: Use Flow for all data streams across layers with
stateIn()for ViewModels andcollectAsStateWithLifecycle()for Compose - this provides lifecycle-aware reactive data with automatic cleanup - Hilt for Dependency Injection: Structure modules by layer (NetworkModule, DatabaseModule, RepositoryModule, UseCaseModule) with appropriate scoping (@Singleton for data layer, @ViewModelScoped for use cases)
- StateFlow for UI State: Expose immutable
StateFlow<UiState>from ViewModels with sealed interfaces for exhaustive state handling in Compose - Pure Kotlin Domain Layer: Domain models and use cases have zero Android dependencies - use
java.time,BigDecimal, and Kotlin coroutines only, enabling fast unit tests without Robolectric - Room as Single Source of Truth: Room database serves as the local cache with Flow-based queries that automatically emit updates when data changes
Clean Architecture Layers
Android Clean Architecture applies the three-layer pattern (see Architecture Overview for conceptual details) using Android-specific technologies:
Android Layer Technologies
Presentation Layer: Jetpack Compose composables, @HiltViewModel ViewModels with StateFlow<UiState>, sealed interface state classes, collectAsStateWithLifecycle() for lifecycle-aware collection
Domain Layer: Pure Kotlin data classes (no android.* imports), use cases with @Inject constructor, repository interfaces defining Flow-based APIs, business validation logic
Data Layer: @Singleton repository implementations, Retrofit interfaces with suspend functions, Room DAOs returning Flow<List<Entity>>, mappers converting between DTO/Entity/Domain
Project Structure
Feature-Based Modular Structure
app/
├── src/
│ ├── main/
│ │ ├── java/com/bank/paymentapp/
│ │ │ │
│ │ │ ├── presentation/ # Presentation Layer
│ │ │ │ ├── navigation/
│ │ │ │ │ └── NavGraph.kt
│ │ │ │ ├── theme/
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Theme.kt
│ │ │ │ │ └── Type.kt
│ │ │ │ ├── components/ # Shared UI components
│ │ │ │ │ ├── LoadingIndicator.kt
│ │ │ │ │ ├── ErrorView.kt
│ │ │ │ │ └── PaymentCard.kt
│ │ │ │ │
│ │ │ │ └── features/ # Feature modules
│ │ │ │ ├── payments/
│ │ │ │ │ ├── list/
│ │ │ │ │ │ ├── PaymentListScreen.kt
│ │ │ │ │ │ ├── PaymentListViewModel.kt
│ │ │ │ │ │ └── PaymentListState.kt
│ │ │ │ │ ├── detail/
│ │ │ │ │ │ ├── PaymentDetailScreen.kt
│ │ │ │ │ │ ├── PaymentDetailViewModel.kt
│ │ │ │ │ │ └── PaymentDetailState.kt
│ │ │ │ │ └── create/
│ │ │ │ │ ├── CreatePaymentScreen.kt
│ │ │ │ │ ├── CreatePaymentViewModel.kt
│ │ │ │ │ └── CreatePaymentState.kt
│ │ │ │ │
│ │ │ │ └── profile/
│ │ │ │ ├── ProfileScreen.kt
│ │ │ │ ├── ProfileViewModel.kt
│ │ │ │ └── ProfileState.kt
│ │ │ │
│ │ │ ├── domain/ # Domain Layer
│ │ │ │ ├── model/ # Domain models
│ │ │ │ │ ├── Payment.kt
│ │ │ │ │ ├── PaymentStatus.kt
│ │ │ │ │ ├── Account.kt
│ │ │ │ │ └── Customer.kt
│ │ │ │ │
│ │ │ │ ├── repository/ # Repository interfaces
│ │ │ │ │ ├── PaymentRepository.kt
│ │ │ │ │ ├── AccountRepository.kt
│ │ │ │ │ └── CustomerRepository.kt
│ │ │ │ │
│ │ │ │ └── usecase/ # Use cases
│ │ │ │ ├── payment/
│ │ │ │ │ ├── GetPaymentsUseCase.kt
│ │ │ │ │ ├── GetPaymentByIdUseCase.kt
│ │ │ │ │ ├── CreatePaymentUseCase.kt
│ │ │ │ │ └── ValidatePaymentUseCase.kt
│ │ │ │ └── account/
│ │ │ │ ├── GetAccountBalanceUseCase.kt
│ │ │ │ └── GetAccountsUseCase.kt
│ │ │ │
│ │ │ ├── data/ # Data Layer
│ │ │ │ ├── local/ # Local data source
│ │ │ │ │ ├── database/
│ │ │ │ │ │ ├── PaymentDatabase.kt
│ │ │ │ │ │ └── Converters.kt
│ │ │ │ │ ├── dao/
│ │ │ │ │ │ ├── PaymentDao.kt
│ │ │ │ │ │ └── AccountDao.kt
│ │ │ │ │ ├── entity/
│ │ │ │ │ │ ├── PaymentEntity.kt
│ │ │ │ │ │ └── AccountEntity.kt
│ │ │ │ │ └── preferences/
│ │ │ │ │ └── SecurePreferences.kt
│ │ │ │ │
│ │ │ │ ├── remote/ # Remote data source
│ │ │ │ │ ├── api/
│ │ │ │ │ │ ├── PaymentApi.kt
│ │ │ │ │ │ └── AccountApi.kt
│ │ │ │ │ ├── dto/
│ │ │ │ │ │ ├── PaymentDto.kt
│ │ │ │ │ │ ├── CreatePaymentRequest.kt
│ │ │ │ │ │ └── CreatePaymentResponse.kt
│ │ │ │ │ └── interceptor/
│ │ │ │ │ ├── AuthInterceptor.kt
│ │ │ │ │ └── ErrorInterceptor.kt
│ │ │ │ │
│ │ │ │ ├── mapper/ # Data mappers
│ │ │ │ │ ├── PaymentMapper.kt
│ │ │ │ │ └── AccountMapper.kt
│ │ │ │ │
│ │ │ │ └── repository/ # Repository implementations
│ │ │ │ ├── PaymentRepositoryImpl.kt
│ │ │ │ └── AccountRepositoryImpl.kt
│ │ │ │
│ │ │ ├── di/ # Dependency Injection
│ │ │ │ ├── AppModule.kt
│ │ │ │ ├── NetworkModule.kt
│ │ │ │ ├── DatabaseModule.kt
│ │ │ │ ├── RepositoryModule.kt
│ │ │ │ └── UseCaseModule.kt
│ │ │ │
│ │ │ └── PaymentApplication.kt # Application class
│ │ │
│ │ └── AndroidManifest.xml
│ │
│ ├── test/ # Unit tests
│ └── androidTest/ # Integration tests
│
└── build.gradle
Domain Layer: Business Logic
Domain Models
Domain models are pure Kotlin data classes with no Android dependencies:
// domain/model/Payment.kt
package com.bank.paymentapp.domain.model
import java.math.BigDecimal
import java.time.Instant
/**
* Domain model representing a payment transaction.
* Pure Kotlin with no Android dependencies for maximum testability.
*/
data class Payment(
val id: String,
val accountId: String,
val recipientName: String,
val recipientAccountNumber: String,
val amount: BigDecimal,
val currency: String,
val status: PaymentStatus,
val description: String?,
val reference: String,
val createdAt: Instant,
val completedAt: Instant? = null
) {
/**
* Business logic: Validate payment amount
*/
fun isValid(): Boolean {
return amount > BigDecimal.ZERO &&
recipientAccountNumber.isNotBlank() &&
currency.length == 3
}
/**
* Business logic: Check if payment can be cancelled
*/
fun canBeCancelled(): Boolean {
return status == PaymentStatus.PENDING || status == PaymentStatus.SCHEDULED
}
/**
* Business logic: Check if payment is completed
*/
fun isCompleted(): Boolean {
return status == PaymentStatus.COMPLETED
}
}
enum class PaymentStatus {
PENDING,
SCHEDULED,
PROCESSING,
COMPLETED,
FAILED,
CANCELLED
}
// domain/model/Account.kt
package com.bank.paymentapp.domain.model
import java.math.BigDecimal
data class Account(
val id: String,
val accountNumber: String,
val accountType: AccountType,
val balance: BigDecimal,
val currency: String,
val customerId: String,
val isActive: Boolean
) {
/**
* Business logic: Check if account has sufficient funds
*/
fun hasSufficientFunds(amount: BigDecimal): Boolean {
return isActive && balance >= amount
}
}
enum class AccountType {
CHECKING,
SAVINGS,
CREDIT
}
Use Cases (Interactors)
Use cases encapsulate business logic and orchestrate data flow:
// domain/usecase/payment/GetPaymentsUseCase.kt
package com.bank.paymentapp.domain.usecase.payment
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.repository.PaymentRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
/**
* Use case for retrieving all payments for the current user.
*
* Implements offline-first strategy:
* 1. Immediately return cached data
* 2. Fetch fresh data from network
* 3. Update cache and emit new data
*/
class GetPaymentsUseCase @Inject constructor(
private val paymentRepository: PaymentRepository
) {
operator fun invoke(): Flow<List<Payment>> {
return paymentRepository.getPayments()
}
}
// domain/usecase/payment/CreatePaymentUseCase.kt
package com.bank.paymentapp.domain.usecase.payment
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.repository.AccountRepository
import com.bank.paymentapp.domain.repository.PaymentRepository
import java.math.BigDecimal
import javax.inject.Inject
/**
* Use case for creating a new payment with business validation.
*
* Business rules:
* - Account must have sufficient funds
* - Amount must be positive
* - Recipient account must be valid
*/
class CreatePaymentUseCase @Inject constructor(
private val paymentRepository: PaymentRepository,
private val accountRepository: AccountRepository,
private val validatePaymentUseCase: ValidatePaymentUseCase
) {
suspend operator fun invoke(
accountId: String,
recipientName: String,
recipientAccountNumber: String,
amount: BigDecimal,
currency: String,
description: String?
): Result<Payment> {
// 1. Get account details
val account = accountRepository.getAccountById(accountId)
?: return Result.failure(Exception("Account not found"))
// 2. Validate account has sufficient funds
if (!account.hasSufficientFunds(amount)) {
return Result.failure(InsufficientFundsException("Insufficient funds"))
}
// 3. Validate payment details
val validationResult = validatePaymentUseCase(
recipientAccountNumber = recipientAccountNumber,
amount = amount,
currency = currency
)
if (validationResult.isFailure) {
return validationResult
}
// 4. Create payment
return paymentRepository.createPayment(
accountId = accountId,
recipientName = recipientName,
recipientAccountNumber = recipientAccountNumber,
amount = amount,
currency = currency,
description = description
)
}
}
class InsufficientFundsException(message: String) : Exception(message)
// domain/usecase/payment/ValidatePaymentUseCase.kt
package com.bank.paymentapp.domain.usecase.payment
import java.math.BigDecimal
import javax.inject.Inject
/**
* Use case for validating payment details.
* Encapsulates business rules for payment validation.
*/
class ValidatePaymentUseCase @Inject constructor() {
operator fun invoke(
recipientAccountNumber: String,
amount: BigDecimal,
currency: String
): Result<Unit> {
// Validate amount
if (amount <= BigDecimal.ZERO) {
return Result.failure(
ValidationException("Amount must be greater than zero")
)
}
// Validate maximum transaction amount (business rule)
if (amount > MAX_TRANSACTION_AMOUNT) {
return Result.failure(
ValidationException("Amount exceeds maximum transaction limit")
)
}
// Validate account number format
if (!isValidAccountNumber(recipientAccountNumber)) {
return Result.failure(
ValidationException("Invalid account number format")
)
}
// Validate currency code
if (!SUPPORTED_CURRENCIES.contains(currency)) {
return Result.failure(
ValidationException("Unsupported currency: $currency")
)
}
return Result.success(Unit)
}
private fun isValidAccountNumber(accountNumber: String): Boolean {
// Business rule: Account number must be 10-16 digits
return accountNumber.matches(Regex("^\\d{10,16}$"))
}
companion object {
private val MAX_TRANSACTION_AMOUNT = BigDecimal("100000.00")
private val SUPPORTED_CURRENCIES = setOf("USD", "EUR", "GBP")
}
}
class ValidationException(message: String) : Exception(message)
Repository Interfaces
Repository interfaces are defined in the domain layer:
// domain/repository/PaymentRepository.kt
package com.bank.paymentapp.domain.repository
import com.bank.paymentapp.domain.model.Payment
import kotlinx.coroutines.flow.Flow
import java.math.BigDecimal
/**
* Repository interface for payment data operations.
* Defined in domain layer, implemented in data layer.
*/
interface PaymentRepository {
/**
* Get all payments as a Flow for reactive updates.
* Returns cached data immediately and updates when network data arrives.
*/
fun getPayments(): Flow<List<Payment>>
/**
* Get a specific payment by ID.
* Returns null if payment not found.
*/
suspend fun getPaymentById(id: String): Payment?
/**
* Create a new payment.
* Returns Result with created payment or error.
*/
suspend fun createPayment(
accountId: String,
recipientName: String,
recipientAccountNumber: String,
amount: BigDecimal,
currency: String,
description: String?
): Result<Payment>
/**
* Cancel a pending payment.
*/
suspend fun cancelPayment(paymentId: String): Result<Payment>
/**
* Refresh payments from remote source.
*/
suspend fun refreshPayments(): Result<Unit>
}
Data Layer: Implementation
Repository Implementation
Repositories implement the single source of truth pattern:
// data/repository/PaymentRepositoryImpl.kt
package com.bank.paymentapp.data.repository
import com.bank.paymentapp.data.local.dao.PaymentDao
import com.bank.paymentapp.data.mapper.PaymentMapper
import com.bank.paymentapp.data.remote.api.PaymentApi
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.repository.PaymentRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import java.math.BigDecimal
import javax.inject.Inject
/**
* Implementation of PaymentRepository.
*
* Strategy: Offline-first with network sync
* 1. Return local cache immediately (Flow)
* 2. Fetch from network in background
* 3. Update local cache
* 4. Flow automatically emits updated data
*/
class PaymentRepositoryImpl @Inject constructor(
private val paymentApi: PaymentApi,
private val paymentDao: PaymentDao,
private val mapper: PaymentMapper
) : PaymentRepository {
override fun getPayments(): Flow<List<Payment>> {
return paymentDao.getAll()
.map { entities -> entities.map { mapper.entityToDomain(it) } }
.onStart {
// Trigger background refresh when Flow starts
refreshPayments()
}
}
override suspend fun getPaymentById(id: String): Payment? {
return paymentDao.getById(id)?.let { mapper.entityToDomain(it) }
}
override suspend fun createPayment(
accountId: String,
recipientName: String,
recipientAccountNumber: String,
amount: BigDecimal,
currency: String,
description: String?
): Result<Payment> {
return try {
// 1. Create payment request DTO
val request = mapper.toCreatePaymentRequest(
accountId = accountId,
recipientName = recipientName,
recipientAccountNumber = recipientAccountNumber,
amount = amount,
currency = currency,
description = description
)
// 2. Call API
val response = paymentApi.createPayment(request)
// 3. Map to domain model
val payment = mapper.dtoToDomain(response.payment)
// 4. Cache locally
paymentDao.insert(mapper.domainToEntity(payment))
Result.success(payment)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun cancelPayment(paymentId: String): Result<Payment> {
return try {
val response = paymentApi.cancelPayment(paymentId)
val payment = mapper.dtoToDomain(response.payment)
// Update local cache
paymentDao.update(mapper.domainToEntity(payment))
Result.success(payment)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun refreshPayments(): Result<Unit> {
return try {
val response = paymentApi.getPayments()
val payments = response.payments.map { mapper.dtoToDomain(it) }
// Replace cache with fresh data
paymentDao.deleteAll()
paymentDao.insertAll(payments.map { mapper.domainToEntity(it) })
Result.success(Unit)
} catch (e: Exception) {
// Silent failure - cache remains valid
Result.failure(e)
}
}
}
Mapper Pattern
Mappers convert between layer boundaries:
// data/mapper/PaymentMapper.kt
package com.bank.paymentapp.data.mapper
import com.bank.paymentapp.data.local.entity.PaymentEntity
import com.bank.paymentapp.data.remote.dto.CreatePaymentRequest
import com.bank.paymentapp.data.remote.dto.PaymentDto
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import java.math.BigDecimal
import java.time.Instant
import javax.inject.Inject
/**
* Mapper for converting between Payment representations across layers.
*
* Responsibilities:
* - DTO <-> Domain Model (API to business logic)
* - Entity <-> Domain Model (Database to business logic)
* - Domain Model <-> Request DTOs
*/
class PaymentMapper @Inject constructor() {
/**
* Map DTO from API to domain model
*/
fun dtoToDomain(dto: PaymentDto): Payment {
return Payment(
id = dto.id,
accountId = dto.accountId,
recipientName = dto.recipientName,
recipientAccountNumber = dto.recipientAccountNumber,
amount = BigDecimal(dto.amount),
currency = dto.currency,
status = PaymentStatus.valueOf(dto.status),
description = dto.description,
reference = dto.reference,
createdAt = Instant.parse(dto.createdAt),
completedAt = dto.completedAt?.let { Instant.parse(it) }
)
}
/**
* Map domain model to database entity
*/
fun domainToEntity(domain: Payment): PaymentEntity {
return PaymentEntity(
id = domain.id,
accountId = domain.accountId,
recipientName = domain.recipientName,
recipientAccountNumber = domain.recipientAccountNumber,
amount = domain.amount.toDouble(),
currency = domain.currency,
status = domain.status.name,
description = domain.description,
reference = domain.reference,
createdAt = domain.createdAt.toEpochMilli(),
completedAt = domain.completedAt?.toEpochMilli()
)
}
/**
* Map database entity to domain model
*/
fun entityToDomain(entity: PaymentEntity): Payment {
return Payment(
id = entity.id,
accountId = entity.accountId,
recipientName = entity.recipientName,
recipientAccountNumber = entity.recipientAccountNumber,
amount = BigDecimal(entity.amount),
currency = entity.currency,
status = PaymentStatus.valueOf(entity.status),
description = entity.description,
reference = entity.reference,
createdAt = Instant.ofEpochMilli(entity.createdAt),
completedAt = entity.completedAt?.let { Instant.ofEpochMilli(it) }
)
}
/**
* Create request DTO for payment creation
*/
fun toCreatePaymentRequest(
accountId: String,
recipientName: String,
recipientAccountNumber: String,
amount: BigDecimal,
currency: String,
description: String?
): CreatePaymentRequest {
return CreatePaymentRequest(
accountId = accountId,
recipientName = recipientName,
recipientAccountNumber = recipientAccountNumber,
amount = amount.toString(),
currency = currency,
description = description
)
}
}
Presentation Layer: MVVM vs MVI
MVVM Pattern (Recommended for Most Cases)
MVVM with StateFlow provides predictable state management:
// presentation/features/payments/list/PaymentListState.kt
package com.bank.paymentapp.presentation.features.payments.list
import com.bank.paymentapp.domain.model.Payment
/**
* UI state for payment list screen.
* Sealed interface ensures exhaustive when statements.
*/
sealed interface PaymentListState {
data object Loading : PaymentListState
data class Success(
val payments: List<Payment>,
val isRefreshing: Boolean = false
) : PaymentListState
data class Error(
val message: String,
val canRetry: Boolean = true
) : PaymentListState
data object Empty : PaymentListState
}
/**
* UI events from user interactions
*/
sealed interface PaymentListEvent {
data object Refresh : PaymentListEvent
data class NavigateToDetail(val paymentId: String) : PaymentListEvent
data object CreatePayment : PaymentListEvent
}
// presentation/features/payments/list/PaymentListViewModel.kt
package com.bank.paymentapp.presentation.features.payments.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bank.paymentapp.domain.usecase.payment.GetPaymentsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PaymentListViewModel @Inject constructor(
private val getPaymentsUseCase: GetPaymentsUseCase
) : ViewModel() {
private val _state = MutableStateFlow<PaymentListState>(PaymentListState.Loading)
val state: StateFlow<PaymentListState> = _state.asStateFlow()
init {
loadPayments()
}
private fun loadPayments() {
viewModelScope.launch {
getPaymentsUseCase()
.catch { error ->
_state.value = PaymentListState.Error(
message = error.message ?: "Failed to load payments"
)
}
.collect { payments ->
_state.value = if (payments.isEmpty()) {
PaymentListState.Empty
} else {
PaymentListState.Success(payments)
}
}
}
}
fun onEvent(event: PaymentListEvent) {
when (event) {
is PaymentListEvent.Refresh -> refresh()
is PaymentListEvent.NavigateToDetail -> {
// Navigation handled by Composable
}
is PaymentListEvent.CreatePayment -> {
// Navigation handled by Composable
}
}
}
private fun refresh() {
val currentState = _state.value
if (currentState is PaymentListState.Success) {
_state.value = currentState.copy(isRefreshing = true)
}
loadPayments()
}
}
MVI Pattern (For Complex State Management)
MVI provides more structured state updates for complex screens:
// presentation/features/payments/create/CreatePaymentState.kt
package com.bank.paymentapp.presentation.features.payments.create
import com.bank.paymentapp.domain.model.Account
import java.math.BigDecimal
/**
* MVI State for create payment screen.
* Single immutable state object.
*/
data class CreatePaymentState(
val accounts: List<Account> = emptyList(),
val selectedAccount: Account? = null,
val recipientName: String = "",
val recipientAccountNumber: String = "",
val amount: String = "",
val description: String = "",
val isLoading: Boolean = false,
val isValidating: Boolean = false,
val validationErrors: Map<Field, String> = emptyMap(),
val submitError: String? = null,
val isSubmitSuccessful: Boolean = false
) {
enum class Field {
RECIPIENT_NAME,
RECIPIENT_ACCOUNT,
AMOUNT,
ACCOUNT
}
val isValid: Boolean
get() = validationErrors.isEmpty() &&
selectedAccount != null &&
recipientName.isNotBlank() &&
recipientAccountNumber.isNotBlank() &&
amount.isNotBlank()
}
/**
* MVI Intent: User actions
*/
sealed interface CreatePaymentIntent {
data class SelectAccount(val account: Account) : CreatePaymentIntent
data class UpdateRecipientName(val name: String) : CreatePaymentIntent
data class UpdateRecipientAccount(val accountNumber: String) : CreatePaymentIntent
data class UpdateAmount(val amount: String) : CreatePaymentIntent
data class UpdateDescription(val description: String) : CreatePaymentIntent
data object Submit : CreatePaymentIntent
data object ValidateForm : CreatePaymentIntent
}
/**
* MVI Effect: One-time events
*/
sealed interface CreatePaymentEffect {
data object NavigateBack : CreatePaymentEffect
data class ShowError(val message: String) : CreatePaymentEffect
data object ShowSuccess : CreatePaymentEffect
}
// presentation/features/payments/create/CreatePaymentViewModel.kt
package com.bank.paymentapp.presentation.features.payments.create
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bank.paymentapp.domain.usecase.account.GetAccountsUseCase
import com.bank.paymentapp.domain.usecase.payment.CreatePaymentUseCase
import com.bank.paymentapp.domain.usecase.payment.ValidatePaymentUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@HiltViewModel
class CreatePaymentViewModel @Inject constructor(
private val getAccountsUseCase: GetAccountsUseCase,
private val createPaymentUseCase: CreatePaymentUseCase,
private val validatePaymentUseCase: ValidatePaymentUseCase
) : ViewModel() {
private val _state = MutableStateFlow(CreatePaymentState())
val state: StateFlow<CreatePaymentState> = _state.asStateFlow()
private val _effect = Channel<CreatePaymentEffect>(Channel.BUFFERED)
val effect: Flow<CreatePaymentEffect> = _effect.receiveAsFlow()
init {
loadAccounts()
}
fun onIntent(intent: CreatePaymentIntent) {
when (intent) {
is CreatePaymentIntent.SelectAccount -> selectAccount(intent.account)
is CreatePaymentIntent.UpdateRecipientName -> updateRecipientName(intent.name)
is CreatePaymentIntent.UpdateRecipientAccount -> updateRecipientAccount(intent.accountNumber)
is CreatePaymentIntent.UpdateAmount -> updateAmount(intent.amount)
is CreatePaymentIntent.UpdateDescription -> updateDescription(intent.description)
is CreatePaymentIntent.Submit -> submitPayment()
is CreatePaymentIntent.ValidateForm -> validateForm()
}
}
private fun loadAccounts() {
viewModelScope.launch {
getAccountsUseCase()
.catch { error ->
_effect.send(
CreatePaymentEffect.ShowError(
error.message ?: "Failed to load accounts"
)
)
}
.collectLatest { accounts ->
_state.update { it.copy(accounts = accounts) }
}
}
}
private fun selectAccount(account: Account) {
_state.update {
it.copy(
selectedAccount = account,
validationErrors = it.validationErrors - CreatePaymentState.Field.ACCOUNT
)
}
}
private fun updateRecipientName(name: String) {
_state.update {
it.copy(
recipientName = name,
validationErrors = it.validationErrors - CreatePaymentState.Field.RECIPIENT_NAME
)
}
}
private fun updateRecipientAccount(accountNumber: String) {
_state.update {
it.copy(
recipientAccountNumber = accountNumber,
validationErrors = it.validationErrors - CreatePaymentState.Field.RECIPIENT_ACCOUNT
)
}
}
private fun updateAmount(amount: String) {
_state.update {
it.copy(
amount = amount,
validationErrors = it.validationErrors - CreatePaymentState.Field.AMOUNT
)
}
}
private fun updateDescription(description: String) {
_state.update { it.copy(description = description) }
}
private fun validateForm() {
val currentState = _state.value
val errors = mutableMapOf<CreatePaymentState.Field, String>()
if (currentState.selectedAccount == null) {
errors[CreatePaymentState.Field.ACCOUNT] = "Please select an account"
}
if (currentState.recipientName.isBlank()) {
errors[CreatePaymentState.Field.RECIPIENT_NAME] = "Recipient name is required"
}
if (currentState.recipientAccountNumber.isBlank()) {
errors[CreatePaymentState.Field.RECIPIENT_ACCOUNT] = "Account number is required"
}
if (currentState.amount.isBlank()) {
errors[CreatePaymentState.Field.AMOUNT] = "Amount is required"
} else {
try {
val amountValue = BigDecimal(currentState.amount)
if (amountValue <= BigDecimal.ZERO) {
errors[CreatePaymentState.Field.AMOUNT] = "Amount must be greater than zero"
}
} catch (e: NumberFormatException) {
errors[CreatePaymentState.Field.AMOUNT] = "Invalid amount format"
}
}
_state.update { it.copy(validationErrors = errors) }
}
private fun submitPayment() {
validateForm()
if (!_state.value.isValid) {
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, submitError = null) }
val currentState = _state.value
val result = createPaymentUseCase(
accountId = currentState.selectedAccount!!.id,
recipientName = currentState.recipientName,
recipientAccountNumber = currentState.recipientAccountNumber,
amount = BigDecimal(currentState.amount),
currency = currentState.selectedAccount!!.currency,
description = currentState.description.ifBlank { null }
)
result
.onSuccess {
_state.update { it.copy(isLoading = false, isSubmitSuccessful = true) }
_effect.send(CreatePaymentEffect.ShowSuccess)
_effect.send(CreatePaymentEffect.NavigateBack)
}
.onFailure { error ->
_state.update {
it.copy(
isLoading = false,
submitError = error.message ?: "Failed to create payment"
)
}
_effect.send(
CreatePaymentEffect.ShowError(
error.message ?: "Failed to create payment"
)
)
}
}
}
}
MVVM Architecture Flow
MVVM Sequence Diagram
This diagram illustrates the complete flow of data and events in the MVVM pattern:
MVVM Data Flow Diagram
This diagram shows the relationships and data flow between MVVM components:
Coordinator Pattern for Navigation
Coordinator Architecture
While Android doesn't have a built-in coordinator pattern like iOS, we can implement navigation management for complex flows:
Navigation Flow Example
Domain Layer Architecture
Domain Layer Components
Use Case Flow with Business Logic
Dependency Injection Architecture
Hilt Dependency Graph
Dependency Injection Flow
Unidirectional Data Flow
Complete Data Flow Architecture
State Flow Direction
Dependency Injection with Hilt
Hilt Modules
// di/RepositoryModule.kt
package com.bank.paymentapp.di
import com.bank.paymentapp.data.repository.AccountRepositoryImpl
import com.bank.paymentapp.data.repository.PaymentRepositoryImpl
import com.bank.paymentapp.domain.repository.AccountRepository
import com.bank.paymentapp.domain.repository.PaymentRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Hilt module for binding repository interfaces to implementations.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindPaymentRepository(
impl: PaymentRepositoryImpl
): PaymentRepository
@Binds
@Singleton
abstract fun bindAccountRepository(
impl: AccountRepositoryImpl
): AccountRepository
}
// di/UseCaseModule.kt
package com.bank.paymentapp.di
import com.bank.paymentapp.domain.repository.AccountRepository
import com.bank.paymentapp.domain.repository.PaymentRepository
import com.bank.paymentapp.domain.usecase.payment.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
/**
* Hilt module for providing use cases.
* Scoped to ViewModelComponent for efficiency.
*/
@Module
@InstallIn(ViewModelComponent::class)
object UseCaseModule {
@Provides
@ViewModelScoped
fun provideGetPaymentsUseCase(
repository: PaymentRepository
): GetPaymentsUseCase {
return GetPaymentsUseCase(repository)
}
@Provides
@ViewModelScoped
fun provideCreatePaymentUseCase(
paymentRepository: PaymentRepository,
accountRepository: AccountRepository,
validatePaymentUseCase: ValidatePaymentUseCase
): CreatePaymentUseCase {
return CreatePaymentUseCase(
paymentRepository,
accountRepository,
validatePaymentUseCase
)
}
@Provides
@ViewModelScoped
fun provideValidatePaymentUseCase(): ValidatePaymentUseCase {
return ValidatePaymentUseCase()
}
}
State Management Patterns
Unidirectional Data Flow
State Hoisting Pattern
// presentation/features/payments/list/PaymentListScreen.kt
@Composable
fun PaymentListScreen(
viewModel: PaymentListViewModel = hiltViewModel(),
onPaymentClick: (String) -> Unit,
onCreatePayment: () -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()
PaymentListContent(
state = state,
onEvent = viewModel::onEvent,
onPaymentClick = onPaymentClick,
onCreatePayment = onCreatePayment
)
}
/**
* Stateless composable - easier to test and preview
*/
@Composable
private fun PaymentListContent(
state: PaymentListState,
onEvent: (PaymentListEvent) -> Unit,
onPaymentClick: (String) -> Unit,
onCreatePayment: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(title = { Text("Payments") })
},
floatingActionButton = {
FloatingActionButton(onClick = onCreatePayment) {
Icon(Icons.Default.Add, contentDescription = "Create Payment")
}
}
) { paddingValues ->
when (state) {
is PaymentListState.Loading -> LoadingView()
is PaymentListState.Success -> PaymentList(
payments = state.payments,
isRefreshing = state.isRefreshing,
onRefresh = { onEvent(PaymentListEvent.Refresh) },
onPaymentClick = onPaymentClick,
modifier = Modifier.padding(paddingValues)
)
is PaymentListState.Error -> ErrorView(
message = state.message,
onRetry = { onEvent(PaymentListEvent.Refresh) }
)
is PaymentListState.Empty -> EmptyView(
message = "No payments yet",
onCreatePayment = onCreatePayment
)
}
}
}
Good vs Bad Examples
Bad: God ViewModel
// BAD: ViewModel with too many responsibilities
@HiltViewModel
class PaymentViewModel @Inject constructor(
private val paymentApi: PaymentApi,
private val paymentDao: PaymentDao,
private val accountApi: AccountApi,
private val accountDao: AccountDao,
private val authManager: AuthManager,
private val analytics: Analytics
) : ViewModel() {
// Direct API and database calls in ViewModel
fun createPayment(/* many parameters */) {
viewModelScope.launch {
try {
// Business logic in ViewModel (BAD!)
val account = accountDao.getById(accountId)
if (account.balance < amount) {
throw Exception("Insufficient funds")
}
// Direct API call (BAD!)
val response = paymentApi.createPayment(/* ... */)
// Direct database write (BAD!)
paymentDao.insert(response.toEntity())
// Analytics in ViewModel (BAD!)
analytics.trackPaymentCreated()
} catch (e: Exception) {
// Error handling
}
}
}
}
Good: Layered Architecture
// GOOD: ViewModel delegates to use case
@HiltViewModel
class CreatePaymentViewModel @Inject constructor(
private val createPaymentUseCase: CreatePaymentUseCase
) : ViewModel() {
fun createPayment(/* ... */) {
viewModelScope.launch {
val result = createPaymentUseCase(/* ... */)
result
.onSuccess { payment ->
_state.update { it.copy(isSuccess = true) }
}
.onFailure { error ->
_state.update { it.copy(error = error.message) }
}
}
}
}
// Business logic in use case (GOOD!)
class CreatePaymentUseCase @Inject constructor(
private val paymentRepository: PaymentRepository,
private val accountRepository: AccountRepository
) {
suspend operator fun invoke(/* ... */): Result<Payment> {
// Business validation
val account = accountRepository.getById(accountId)
?: return Result.failure(Exception("Account not found"))
if (!account.hasSufficientFunds(amount)) {
return Result.failure(Exception("Insufficient funds"))
}
// Delegate data operations to repository
return paymentRepository.createPayment(/* ... */)
}
}
Common Mistakes
Architecture Anti-Patterns
Business Logic in ViewModel
// BAD: Business logic directly in ViewModel
@HiltViewModel
class PaymentViewModel @Inject constructor(
private val paymentRepository: PaymentRepository,
private val accountRepository: AccountRepository
) : ViewModel() {
fun createPayment(accountId: String, amount: BigDecimal) {
viewModelScope.launch {
// Business validation in ViewModel (BAD!)
val account = accountRepository.getAccountById(accountId)
if (account == null) {
_state.value = Error("Account not found")
return@launch
}
if (account.balance < amount) {
_state.value = Error("Insufficient funds")
return@launch
}
// Direct repository call without use case (BAD!)
val result = paymentRepository.createPayment(...)
}
}
}
// GOOD: Delegate to use case
@HiltViewModel
class PaymentViewModel @Inject constructor(
private val createPaymentUseCase: CreatePaymentUseCase
) : ViewModel() {
fun createPayment(accountId: String, amount: BigDecimal) {
viewModelScope.launch {
val result = createPaymentUseCase(accountId, amount)
_state.value = when {
result.isSuccess -> Success(result.getOrNull()!!)
result.isFailure -> Error(result.exceptionOrNull()?.message)
}
}
}
}
Android Dependencies in Domain Layer
// BAD: Android dependency in domain model
package com.bank.paymentapp.domain.model
import android.os.Parcelable // Android import in domain!
import kotlinx.parcelize.Parcelize
@Parcelize // Android-specific annotation
data class Payment(
val id: String,
val amount: BigDecimal
) : Parcelable // Android interface
// GOOD: Pure Kotlin domain model
package com.bank.paymentapp.domain.model
data class Payment(
val id: String,
val amount: BigDecimal
)
// If needed, create separate UI model in presentation layer
package com.bank.paymentapp.presentation.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class PaymentUiModel(
val id: String,
val amount: String // Formatted for display
) : Parcelable
Repository Implementation in Domain Layer
// BAD: Concrete implementation in domain layer
package com.bank.paymentapp.domain.repository
import com.bank.paymentapp.data.remote.api.PaymentApi // Data layer import!
class PaymentRepository(
private val api: PaymentApi // Concrete dependency
) {
suspend fun getPayments(): List<Payment> {
return api.getPayments().map { it.toDomain() }
}
}
// GOOD: Interface in domain, implementation in data
// domain/repository/PaymentRepository.kt
package com.bank.paymentapp.domain.repository
interface PaymentRepository {
fun getPayments(): Flow<List<Payment>>
}
// data/repository/PaymentRepositoryImpl.kt
package com.bank.paymentapp.data.repository
class PaymentRepositoryImpl @Inject constructor(
private val api: PaymentApi,
private val dao: PaymentDao
) : PaymentRepository {
override fun getPayments(): Flow<List<Payment>> {
// Implementation
}
}
Mutable State Exposed
// BAD: Exposing mutable state
class PaymentViewModel @Inject constructor() : ViewModel() {
val state = MutableStateFlow<PaymentState>(Loading) // Mutable public property
fun loadPayments() {
// Anyone can mutate state from outside
}
}
// GOOD: Immutable public interface
class PaymentViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow<PaymentState>(Loading)
val state: StateFlow<PaymentState> = _state.asStateFlow() // Immutable
fun loadPayments() {
_state.value = Success(payments)
}
}
Mixing DTOs with Domain Models
// BAD: Using DTOs in UI
@HiltViewModel
class PaymentListViewModel @Inject constructor(
private val api: PaymentApi // Direct API dependency
) : ViewModel() {
val payments = MutableStateFlow<List<PaymentDto>>(emptyList()) // DTO in UI
fun loadPayments() {
viewModelScope.launch {
payments.value = api.getPayments() // No mapping
}
}
}
@Composable
fun PaymentCard(payment: PaymentDto) { // DTO in UI
Text(payment.created_at) // Snake case from API
}
// GOOD: Use domain models throughout
@HiltViewModel
class PaymentListViewModel @Inject constructor(
private val getPaymentsUseCase: GetPaymentsUseCase // Use case
) : ViewModel() {
val payments = getPaymentsUseCase() // Returns Flow<List<Payment>>
}
@Composable
fun PaymentCard(payment: Payment) { // Domain model
Text(payment.createdAt.format()) // Proper types
}
Code Review Checklist
Use this checklist when reviewing architecture implementations:
Layer Separation
- Domain layer has no Android dependencies (no
android.*imports) - Domain layer is pure Kotlin (can be compiled without Android SDK)
- Business logic is in use cases, not in ViewModels or repositories
- Repository interfaces are in domain layer, implementations in data layer
- ViewModels only coordinate, they don't contain business rules
Dependency Direction
- Dependencies point inward (Presentation → Domain ← Data)
- Domain layer has no dependencies on outer layers
- Use cases depend on repository interfaces, not implementations
- ViewModels inject use cases, not repositories directly
- No circular dependencies between layers
State Management
- StateFlow is immutable (private mutable, public immutable via
asStateFlow()) - UI state is sealed interface or data class with clear states
- Loading, Success, Error states are explicit
- State updates are centralized in ViewModel
- No state mutation from UI (events only)
Data Flow
- Unidirectional data flow (events up, state down)
- Repository is single source of truth
- Mappers convert between layers (DTO ↔ Domain ↔ Entity)
- DTOs stay in data layer, never exposed to presentation
- Domain models are used in ViewModels and UI
Dependency Injection
- Constructor injection is used (not field injection)
- Interfaces are injected, not concrete types
- Proper scoping (@Singleton for repositories, @ViewModelScoped for use cases)
- Modules are organized by layer (Network, Database, Repository, UseCase)
- No manual instantiation of dependencies
Use Cases
- One responsibility per use case (GetPayments, CreatePayment, not PaymentManager)
- Use cases are reusable across multiple ViewModels
- Validation is in use cases or domain models, not ViewModels
- Use cases return Result<T> or Flow<T> for error handling
- Complex business logic is testable without Android framework
Repository Pattern
- Repository implements offline-first (cache then network)
- Room database is used for local cache
- Flow is used for reactive updates
- Error handling is consistent (Result wrapper or exception strategy)
- Network failures don't crash app (cached data remains valid)
Testing Considerations
- Domain layer is testable without Android (pure unit tests)
- ViewModels are testable with fake use cases
- Use cases are testable with fake repositories
- Repositories are testable with fake APIs and DAOs
- Each layer can be tested independently
Banking-Specific Checks
- BigDecimal is used for currency amounts (never Double or Float)
- Sensitive data is not logged (account numbers, amounts)
- Business rules are testable (funds checks, limits, validation)
- Audit logging is present for financial operations
- Error messages are user-friendly (not technical stack traces)
Further Reading
Android Framework Guidelines
- Android Overview - Project setup, Gradle, and Hilt DI
- Android UI - Jetpack Compose and MVVM presentation layer
- Android Data - Retrofit, Room, and repository implementation
- Android Security - Secure architecture patterns
- Android Testing - Testing strategies for layered architecture
- Android Performance - Performance optimization per layer
Architecture Guidelines
- Architecture Overview - Clean Architecture concepts and dependency rule
- Microservices Architecture - Backend architecture patterns
- Mobile Overview - Mobile architecture guidance
- Mobile Navigation - Navigation architecture
Language and Patterns
- Kotlin Best Practices - Kotlin idioms and patterns
- Kotlin Concurrency - Coroutines in architecture
- API Overview - API integration patterns
- Data Overview - Data layer architecture
External Resources
- Android Architecture Guide - Official Google guidelines
- Clean Architecture - Uncle Bob's original article
- MVI Architecture - Hannes Dorfmann's MVI guide
- Kotlin Flow - Kotlin Flow documentation
- Hilt Documentation - Official Hilt guide
Summary
Key Takeaways
- Clean Architecture layers - Separate Domain (business logic), Data (sources), and Presentation (UI)
- Dependency rule - Dependencies point inward; domain layer has no dependencies
- Repository pattern - Single source of truth with offline-first strategy
- Use cases - Encapsulate business logic and orchestrate data flow
- Mapper pattern - Convert between layer boundaries (DTO/Entity/Domain)
- MVVM for simplicity - StateFlow-based ViewModels for most screens
- MVI for complexity - Single state object with intents/effects for complex screens
- Hilt modules - Proper module organization (Network, Database, Repository, UseCase)
- Testability - Each layer can be tested independently with test doubles
- Unidirectional data flow - Events flow up, state flows down
Next Steps: Review Android Testing to learn how to test each architectural layer independently, Android UI for Compose implementation details, and Android Overview for Hilt configuration and project setup.