mirror of
https://github.com/luxwallet/android.git
synced 2026-10-30 20:26:11 +00:00
9.4 KiB
9.4 KiB
ViewModel-Service Architecture Pattern
This document describes the architecture pattern used for ViewModels and Services in this codebase. Follow this pattern when creating new features.
Overview
The pattern separates concerns between:
- Services: Handle business logic, validation, and state management for specific domains
- ViewModel: Orchestrates services, collects their states, and exposes a unified UI state
┌─────────────┐ stateFlow ┌─────────────┐
│ Service │ ─────────────────► │ ViewModel │
└─────────────┘ │ │
│ - collect │
┌─────────────┐ stateFlow │ - handle* │
│ Service │ ─────────────────► │ - emitState │
└─────────────┘ │ │
│ createState │
▲ └──────┬──────┘
│ │
│ setX() / actions │ UiState
│ ▼
│ ┌─────────────┐
└───────────────────────── │ View │
user actions └─────────────┘
Service Pattern
Services extend ServiceState<T> and manage a specific domain of state.
Structure
package io.horizontalsystems.bankwallet.modules.feature
import io.horizontalsystems.bankwallet.core.ServiceState
class FeatureService(
// Dependencies injected via constructor
private val dependency: SomeDependency
) : ServiceState<FeatureService.State>() {
// Private state variables
private var value: String? = null
private var error: Throwable? = null
// Required: Create state from private variables
override fun createState() = State(
value = value,
error = error
)
// Public setters - receive input, update state, emit
fun setValue(newValue: String?) {
this.value = newValue
validate() // Optional: run validation/refresh logic
emitState()
}
// Private logic methods
private fun validate() {
error = if (value.isNullOrBlank()) {
InvalidValueException()
} else {
null
}
}
// Nested State data class - exposed to ViewModel
data class State(
val value: String?,
val error: Throwable?
)
}
class InvalidValueException : Exception()
Key Rules
- Extend
ServiceState<Service.State>() - Private state variables - never expose mutable state directly
createState()- maps private state to immutableStatedata classsetX()methods - public entry points that:- Update private state
- Run validation/refresh logic if needed
- Call
emitState()at the end
- Nested
Statedata class - contains only the data needed by ViewModel
Example: Real Service
class TokenBalanceService(
private val adapterManager: IAdapterManager,
) : ServiceState<TokenBalanceService.State>() {
private var token: Token? = null
private var balance: BigDecimal? = null
private var error: Throwable? = null
override fun createState() = State(
balance = balance,
error = error
)
fun setToken(token: Token?) {
this.token = token
refreshBalance()
validate()
emitState()
}
fun setAmount(amount: BigDecimal?) {
validate()
emitState()
}
private fun refreshBalance() {
val adapter = token?.let { adapterManager.getAdapter(it) }
balance = adapter?.balanceData?.available
}
private fun validate() {
// validation logic
}
data class State(
val balance: BigDecimal?,
val error: Throwable?
)
}
ViewModel Pattern
ViewModels extend ViewModelUiState<T> and orchestrate multiple services.
Structure
package io.horizontalsystems.bankwallet.modules.feature
import androidx.lifecycle.viewModelScope
import io.horizontalsystems.bankwallet.core.ViewModelUiState
import kotlinx.coroutines.launch
class FeatureViewModel(
// Services injected via constructor
private val serviceA: ServiceA,
private val serviceB: ServiceB,
) : ViewModelUiState<FeatureUiState>() {
// Local state mirrors service states
private var stateA = serviceA.stateFlow.value
private var stateB = serviceB.stateFlow.value
// Simple properties without logic can live here (exception)
private var memo: String? = null
init {
// Collect each service's stateFlow
viewModelScope.launch {
serviceA.stateFlow.collect {
handleUpdatedStateA(it)
}
}
viewModelScope.launch {
serviceB.stateFlow.collect {
handleUpdatedStateB(it)
}
}
}
// Required: Create UI state from all local states
override fun createState() = FeatureUiState(
valueA = stateA.value,
valueB = stateB.value,
memo = memo,
error = stateA.error ?: stateB.error,
canSubmit = stateA.canSubmit && stateB.canSubmit
)
// Public functions - delegate to services
fun onEnterValueA(value: String?) {
serviceA.setValue(value)
}
fun onEnterValueB(value: String?) {
serviceB.setValue(value)
}
// Exception: Simple property without logic
fun onEnterMemo(value: String) {
memo = value.ifBlank { null }
emitState()
}
// Private handlers - update local state, trigger side effects, emit
private fun handleUpdatedStateA(state: ServiceA.State) {
this.stateA = state
// Side effects: notify other services if needed
serviceB.setDependentValue(state.value)
emitState()
}
private fun handleUpdatedStateB(state: ServiceB.State) {
this.stateB = state
emitState()
}
}
// UI State - single source of truth for the View
data class FeatureUiState(
val valueA: String?,
val valueB: String?,
val memo: String?,
val error: Throwable?,
val canSubmit: Boolean
)
Key Rules
- Extend
ViewModelUiState<UiState>() - Services injected via constructor
- Local state variables - mirror each service's current state
- Collect in
init- useviewModelScope.launch { stateFlow.collect { } } - Handler pattern -
handleUpdatedXState(state):- Update local state
- Trigger side effects on other services
- Call
emitState()
createState()- combines all local state into singleUiState- Public functions - delegate to services (e.g.,
onEnterX()→service.setX()) - All state in UiState - avoid separate
mutableStateOfproperties
State Ownership Rules
| Has Logic? | Where to Put State |
|---|---|
| Yes (validation, async, transformation) | Service (default) |
| No (simple pass-through) | ViewModel (exception) |
Example: When to Use Service vs ViewModel
// ✅ Service - has validation logic
class AmountService : ServiceState<State>() {
private var amount: BigDecimal? = null
private var error: Throwable? = null
fun setAmount(value: BigDecimal?) {
amount = value
error = if (value != null && value > maxAmount) {
ExceedsMaxError()
} else null
emitState()
}
}
// ✅ ViewModel - simple pass-through, no logic
class SendViewModel(...) : ViewModelUiState<UiState>() {
private var memo: String? = null
fun onEnterMemo(value: String) {
memo = value.ifBlank { null }
emitState()
}
}
Collection Pattern
init {
viewModelScope.launch {
service.stateFlow.collect {
handleUpdatedState(it)
}
}
}
Factory Pattern
Create services in the Module's Factory class:
class FeatureViewModel {
class Factory(
private val wallet: Wallet
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
// Create services
val serviceA = ServiceA(App.dependency1)
val serviceB = ServiceB(App.dependency2)
val serviceC = ServiceC()
// Create and return ViewModel
return FeatureViewModel(
serviceA,
serviceB,
serviceC,
// other dependencies
) as T
}
}
}
Checklist for New Features
- Create Service(s) extending
ServiceState<State>() - Define private state variables in Service
- Implement
createState()in Service - Add
setX()methods withemitState()at the end - Create ViewModel extending
ViewModelUiState<UiState>() - Inject services via constructor
- Add local state variables mirroring service states
- Collect service flows in
initusingviewModelScope.launch - Implement
handleUpdatedXState()handlers - Implement
createState()combining all states - Create Factory in Module to wire dependencies
- Add all UI-relevant state to
UiStatedata class