What Is Single Source of Truth?
Single Source of Truth (SSOT) is an architectural principle that states each piece of data in your application should have exactly one authoritative owner. Every other layer that needs that data derives it from that single owner — no copies, no local caches that diverge, no parallel representations.
Violating SSOT is one of the most common sources of sync bugs in Android apps: you update the database but forget to update the in-memory list, or you update a ViewModel field but the Fragment has its own copy. The data drifts, and the UI shows stale or contradictory information.
The principle applies at every level:
- Persistent data — the Room database (or backend) is the source of truth, not an in-memory list
- UI state — the ViewModel is the source of truth for what the screen should display
- Derived values — computed from the source of truth, never stored separately
State Hoisting in Jetpack Compose
State hoisting is the Compose pattern that enforces SSOT at the UI layer. Instead of a composable owning its own state internally, you move (hoist) that state up to the caller. The composable becomes stateless — it receives values and emits events.
Stateful (before hoisting) — avoid this pattern
@Composable
fun SearchBar() {
// state lives inside the composable — hard to test, hard to share
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search...") }
)
}
The problem: if the parent needs to know the current query (to trigger a search, clear it, or restore it after navigation), it cannot — the state is trapped inside SearchBar.
Stateless (after hoisting) — preferred
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search...") },
modifier = modifier
)
}
// The caller owns the state
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SearchBar(
query = uiState.query,
onQueryChange = viewModel::onQueryChange
)
}
Now the state lives in one place (the ViewModel), and SearchBar is a pure function of its inputs — trivial to test and preview.
ViewModel as SSOT for UI State
The ViewModel is the canonical SSOT for everything the current screen needs to display. It aggregates data from repositories and exposes a single uiState stream.
The encapsulation pattern: private MutableStateFlow, public StateFlow
@HiltViewModel
class ProductListViewModel @Inject constructor(
private val productRepository: ProductRepository
) : ViewModel() {
// Private mutable — only this ViewModel can write
private val _uiState = MutableStateFlow(ProductListUiState())
// Public immutable — UI can only read
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
init {
loadProducts()
}
private fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
productRepository.getProducts()
.catch { throwable ->
_uiState.update { it.copy(
isLoading = false,
errorMessage = throwable.message
)}
}
.collect { products ->
_uiState.update { it.copy(
isLoading = false,
products = products,
errorMessage = null
)}
}
}
}
fun onSearchQueryChanged(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
}
data class ProductListUiState(
val isLoading: Boolean = false,
val products: List<Product> = emptyList(),
val searchQuery: String = "",
val errorMessage: String? = null
)
Why asStateFlow() matters: without it, the UI could cast uiState back to MutableStateFlow and write to it directly, bypassing the ViewModel. The asStateFlow() call returns a read-only wrapper that prevents this.
Derived state — do not duplicate
If a value can be computed from existing state, never store it separately:
// BAD — filteredProducts is a copy that can drift
data class ProductListUiState(
val products: List<Product> = emptyList(),
val searchQuery: String = "",
val filteredProducts: List<Product> = emptyList() // duplicate!
)
// GOOD — derive at the point of use
data class ProductListUiState(
val products: List<Product> = emptyList(),
val searchQuery: String = ""
) {
val filteredProducts: List<Product>
get() = if (searchQuery.isBlank()) products
else products.filter { it.name.contains(searchQuery, ignoreCase = true) }
}
Database as SSOT for Persistent Data
For data that must persist, the Room database (not the in-memory list) is the source of truth. The correct pattern: write to database, observe the database stream, let the UI react automatically.
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY createdAt DESC")
fun observeAll(): Flow<List<Task>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(task: Task)
@Update
suspend fun update(task: Task)
}
class TaskRepository @Inject constructor(private val dao: TaskDao) {
// Repository exposes the DB stream directly — DB is the SSOT
val tasks: Flow<List<Task>> = dao.observeAll()
suspend fun addTask(task: Task) {
dao.insert(task) // write to DB
// DO NOT also update an in-memory list here — the Flow handles it
}
suspend fun toggleComplete(task: Task) {
dao.update(task.copy(isComplete = !task.isComplete))
// The Flow emits the new list automatically
}
}
Any write goes to the database; any read comes from the database. There is no separate in-memory list to keep in sync.
What Happens When State Is Duplicated
Here is a real-world example of sync bugs caused by SSOT violations:
// ANTI-PATTERN: state in two places
class CartViewModel : ViewModel() {
private val _cartItems = MutableStateFlow<List<CartItem>>(emptyList())
val cartItems: StateFlow<List<CartItem>> = _cartItems.asStateFlow()
// BAD: a separate variable for item count
private var _itemCount = MutableStateFlow(0)
val itemCount: StateFlow<Int> = _itemCount.asStateFlow()
fun addToCart(item: CartItem) {
val updated = _cartItems.value + item
_cartItems.value = updated
// Oops — forgot to update _itemCount in some code paths
// Now cartItems.size != itemCount
}
fun removeFromCart(item: CartItem) {
_cartItems.value = _cartItems.value - item
// Forgot _itemCount update entirely — bug!
}
}
// CORRECT: derive itemCount from the single list
class CartViewModel : ViewModel() {
private val _cartItems = MutableStateFlow<List<CartItem>>(emptyList())
val cartItems: StateFlow<List<CartItem>> = _cartItems.asStateFlow()
// Derived — always consistent, zero maintenance
val itemCount: StateFlow<Int> = _cartItems
.map { it.size }
.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
fun addToCart(item: CartItem) {
_cartItems.update { it + item }
// itemCount updates automatically
}
fun removeFromCart(item: CartItem) {
_cartItems.update { it - item }
// itemCount updates automatically
}
}
Sharing State Across Multiple ViewModels
When two screens need the same data, the source of truth should be a shared repository, not one ViewModel copying data to another:
// Shared source of truth at the repository layer
class UserRepository @Inject constructor(
private val userDao: UserDao,
private val api: UserApi
) {
// All ViewModels that need user data observe this single stream
val currentUser: Flow<User?> = userDao.observeCurrentUser()
suspend fun refreshUser() {
val user = api.getMe()
userDao.insert(user)
// Both screens react automatically via their collected flows
}
}
@HiltViewModel
class ProfileViewModel @Inject constructor(userRepository: UserRepository) : ViewModel() {
val uiState = userRepository.currentUser
.map { user -> ProfileUiState(user) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState())
}
@HiltViewModel
class HeaderViewModel @Inject constructor(userRepository: UserRepository) : ViewModel() {
val avatarUrl = userRepository.currentUser
.map { it?.avatarUrl }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}
StateFlow vs SharedFlow for SSOT
| Feature | StateFlow | SharedFlow |
|---|---|---|
| Initial value required | Yes | No |
| Replays latest value to new collectors | Always (1) | Configurable |
| Suitable for UI state | Yes | Rarely |
| Suitable for one-off events | No | Yes |
.value property | Yes | No |
StateFlow is almost always the right choice for UI state because new collectors (e.g., after screen rotation) immediately receive the current state without waiting for the next emission.
Collecting State Safely in the UI
// Fragment
class ProductListFragment : Fragment() {
private val viewModel: ProductListViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// collectAsStateWithLifecycle stops collection when UI is not visible
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
renderState(state)
}
}
}
}
}
// Compose — equivalent approach
@Composable
fun ProductListScreen(viewModel: ProductListViewModel = hiltViewModel()) {
// collectAsStateWithLifecycle is the Compose equivalent of repeatOnLifecycle(STARTED)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ProductListContent(uiState)
}
Key Takeaways
| Concept | Rule |
|---|---|
| SSOT | Each piece of data has exactly one authoritative owner |
| State hoisting | Move state up so composables are stateless functions of inputs |
| MutableStateFlow | Keep private; expose as StateFlow via asStateFlow() |
| Derived values | Compute from the source; never store a duplicate |
| Database | Write-through pattern: write to DB, read from DB Flow |
| Shared data | Put SSOT in repository, not in one ViewModel talking to another |
StateFlow | Preferred for UI state — replays latest value to new collectors |