Skip to main content

Overview

TecMeli uses the Model-View-ViewModel (MVVM) architectural pattern in combination with Clean Architecture. MVVM provides a clear separation between UI logic and business logic, making the codebase more maintainable and testable.

MVVM Components

View

Jetpack Compose UI that observes state and handles user interactions

ViewModel

Manages UI state and orchestrates business logic via use cases

Model

Domain models representing business data

View Layer (Jetpack Compose)

Composable Screens

Views in TecMeli are built with Jetpack Compose, a declarative UI framework.
// ui/screen/home/HomeScreen.kt
@Composable
fun HomeScreen(
    navigateToDetail: (String) -> Unit,
    viewModel: HomeViewModel = hiltViewModel()  // ViewModel injection
) {
    var query by remember { mutableStateOf("") }
    val uiState by viewModel.uiState.collectAsState()  // Observe state

    Scaffold { paddingValues ->
        Column {
            SearchBarComponent(
                query = query,
                onQueryChange = { query = it },
                onSearch = { 
                    viewModel.searchProducts(it)  // Action to ViewModel
                }
            )

            when (val state = uiState) {  // React to state
                is UiState.Loading -> CircularProgressIndicator()
                is UiState.Success -> ProductList(state.data, navigateToDetail)
                is UiState.Error -> ErrorState(state.exception)
                is UiState.Empty -> EmptyMessage()
            }
        }
    }
}
Key aspects:
  • Stateless: Screen doesn’t manage business logic
  • Observes: Uses collectAsState() to observe ViewModel’s StateFlow
  • Reacts: UI updates automatically when state changes
  • Delegates: User actions are passed to ViewModel

ViewModel Injection

viewModel: HomeViewModel = hiltViewModel()
The hiltViewModel() function:
  • Automatically provides ViewModel dependencies via Hilt
  • Scopes ViewModel to the Navigation destination
  • Ensures ViewModel survives configuration changes

ViewModel Layer

Purpose

ViewModels in TecMeli:
  • Hold and manage UI state
  • Survive configuration changes (rotation, theme changes)
  • Invoke use cases to execute business logic
  • Transform domain data into UI-friendly state
  • Launch coroutines for async operations

HomeViewModel Example

// ui/screen/home/HomeViewModel.kt
@HiltViewModel  // Enables Hilt injection
class HomeViewModel @Inject constructor(
    private val getProductsUseCase: GetProductsUseCase  // Use case dependency
) : ViewModel() {

    // Private mutable state
    private val _uiState = MutableStateFlow<UiState<List<Product>>>(UiState.Empty)
    
    // Public immutable state for UI
    val uiState: StateFlow<UiState<List<Product>>> = _uiState.asStateFlow()

    /**
     * Initiates product search based on user query
     */
    fun searchProducts(query: String) {
        // Validation logic
        if (query.isBlank()) {
            _uiState.value = UiState.Empty
            return
        }

        // Launch coroutine in ViewModel scope
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            
            // Invoke use case
            getProductsUseCase(query)
                .onSuccess { products ->
                    _uiState.value = if (products.isEmpty()) {
                        UiState.Empty
                    } else {
                        UiState.Success(products)
                    }
                }
                .onFailure { error ->
                    _uiState.value = UiState.Error(
                        message = error.localizedMessage ?: "Unexpected error",
                        exception = error
                    )
                }
        }
    }
}
Key features:
private val _uiState = MutableStateFlow<UiState<List<Product>>>(UiState.Empty)
val uiState: StateFlow<UiState<List<Product>>> = _uiState.asStateFlow()
  • _uiState: Private, mutable, only ViewModel can modify
  • uiState: Public, immutable, UI observes this
  • Pattern ensures unidirectional data flow
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getProductsUseCase: GetProductsUseCase
)
  • @HiltViewModel: Tells Hilt this is a ViewModel
  • @Inject constructor: Dependencies are injected automatically
  • No manual instantiation needed
viewModelScope.launch {
    // Async work
}
  • viewModelScope: Lifecycle-aware coroutine scope
  • Automatically cancelled when ViewModel is cleared
  • Prevents memory leaks
getProductsUseCase(query)
    .onSuccess { products -> /* ... */ }
    .onFailure { error -> /* ... */ }
  • ViewModel doesn’t contain business logic
  • Delegates to use cases
  • Handles Result type from use cases

ProductDetailViewModel Example

// ui/screen/product_detail/ProductDetailViewModel.kt
@HiltViewModel
class ProductDetailViewModel @Inject constructor(
    private val getProductDetailUseCase: GetProductDetailUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState<ProductDetail>>(UiState.Empty)
    val uiState: StateFlow<UiState<ProductDetail>> = _uiState.asStateFlow()

    fun getProductDetail(id: String) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            getProductDetailUseCase(id)
                .onSuccess { detail ->
                    _uiState.value = UiState.Success(detail)
                }
                .onFailure { error ->
                    _uiState.value = UiState.Error(
                        message = error.localizedMessage ?: "Error loading detail",
                        exception = error
                    )
                }
        }
    }
}
Pattern consistency: All ViewModels follow the same structure, making the codebase predictable.

UiState Model

TecMeli uses a sealed class to represent all possible UI states.
// core/util/UiState.kt
sealed class UiState<out T> {
    /** Loading state - operation in progress */
    object Loading : UiState<Nothing>()

    /** Success with data */
    data class Success<T>(val data: T) : UiState<T>()

    /** Error state with message and exception */
    data class Error(
        val message: String, 
        val exception: Throwable? = null
    ) : UiState<Nothing>()

    /** Empty state - no data to show */
    object Empty : UiState<Nothing>()
}

Why Sealed Class?

Exhaustive when

Compiler ensures all states are handled in when expressions

Type Safety

Success state contains typed data, Error contains exception

Clear States

All possible states are visible in one place

No Nulls

No nullable data that might be undefined

Usage in Composables

when (val state = uiState) {
    is UiState.Loading -> {
        CircularProgressIndicator(Modifier.align(Alignment.Center))
    }
    is UiState.Success -> {
        ProductList(
            products = state.data,  // Type-safe data access
            onItemClick = navigateToDetail
        )
    }
    is UiState.Error -> {
        ErrorState(
            error = state.exception as? AppError ?: AppError.Unknown(state.exception),
            onRetry = { viewModel.searchProducts(query) }
        )
    }
    is UiState.Empty -> {
        Text("Start searching products")
    }
}
Benefits:
  • Compiler error if any state is missed
  • Smart casting gives type-safe access to state.data
  • Clear mapping of state to UI

Data Flow (Unidirectional)

MVVM in TecMeli follows unidirectional data flow:
1

User Action

User clicks search button
SearchBar(onSearch = { query -> viewModel.searchProducts(query) })
2

ViewModel Receives Event

ViewModel method is called
fun searchProducts(query: String) { ... }
3

State to Loading

ViewModel updates state to Loading
_uiState.value = UiState.Loading
4

Use Case Invoked

ViewModel calls use case
getProductsUseCase(query)
5

Result Returned

Use case returns Result with data or error
6

State Updated

ViewModel updates state based on result
.onSuccess { _uiState.value = UiState.Success(it) }
.onFailure { _uiState.value = UiState.Error(...) }
7

StateFlow Emits

StateFlow emits new value to observers
8

UI Recomposes

Composable observes change and recomposes
val uiState by viewModel.uiState.collectAsState()
when (val state = uiState) { ... }

StateFlow vs LiveData

TecMeli uses StateFlow instead of LiveData:
FeatureStateFlowLiveData
Kotlin-firstYesAndroid-specific
Lifecycle-awareNo (handled by Compose)Yes
BackpressureYesNo
OperatorsFull Flow operatorsLimited
TestingEasy (pure Kotlin)Requires Android
Initial valueRequiredOptional
Why StateFlow in TecMeli?
Jetpack Compose handles lifecycle automatically via collectAsState(), making StateFlow a better fit than LiveData.
// In Composable - automatic lifecycle handling
val uiState by viewModel.uiState.collectAsState()
Compose stops collecting when the Composable leaves composition, preventing memory leaks.

ViewModel Lifecycle

Key points:
  • ViewModel outlives configuration changes (rotation)
  • viewModelScope is automatically cancelled on clear
  • Prevents memory leaks from running coroutines
  • State is preserved during rotation

Testing ViewModels

ViewModels are easily testable because they depend on abstractions (use cases).
// ui/screen/home/HomeViewModelTest.kt
class HomeViewModelTest {
    
    private lateinit var viewModel: HomeViewModel
    private val mockGetProductsUseCase = mockk<GetProductsUseCase>()
    
    @Before
    fun setup() {
        viewModel = HomeViewModel(mockGetProductsUseCase)
    }
    
    @Test
    fun `searchProducts updates state to Loading then Success`() = runTest {
        // Given
        val products = listOf(
            Product(id = "1", title = "Laptop")
        )
        coEvery { mockGetProductsUseCase("laptop") } returns Result.success(products)
        
        // When
        viewModel.searchProducts("laptop")
        
        // Then
        val states = mutableListOf<UiState<List<Product>>>()
        viewModel.uiState.take(2).toList(states)
        
        assertEquals(UiState.Loading, states[0])
        assertEquals(UiState.Success(products), states[1])
    }
    
    @Test
    fun `blank query sets state to Empty`() = runTest {
        // When
        viewModel.searchProducts("")
        
        // Then
        assertEquals(UiState.Empty, viewModel.uiState.value)
        coVerify(exactly = 0) { mockGetProductsUseCase(any()) }
    }
}

Best Practices in TecMeli

ViewModel holds the single source of truth for UI state. UI never maintains its own state for business data.
// Good: State in ViewModel
val uiState by viewModel.uiState.collectAsState()

// Bad: State in Composable
var products by remember { mutableStateOf<List<Product>>(emptyList()) }
Public state is always immutable (StateFlow), private state is mutable (MutableStateFlow).
private val _uiState = MutableStateFlow(...)
val uiState: StateFlow<...> = _uiState.asStateFlow()
ViewModels orchestrate, use cases contain logic.
// Good: Delegate to use case
fun searchProducts(query: String) {
    viewModelScope.launch {
        getProductsUseCase(query)  // Use case handles logic
    }
}

// Bad: Logic in ViewModel
fun searchProducts(query: String) {
    viewModelScope.launch {
        val response = repository.search(query)  // Too much logic
        val filtered = response.filter { it.isActive }
        // ...
    }
}
Always use viewModelScope for coroutines, never GlobalScope or manual coroutine scopes.
// Good
viewModelScope.launch { ... }

// Bad
GlobalScope.launch { ... }  // Leaks!

Common Patterns

Loading State Management

fun loadData() {
    viewModelScope.launch {
        _uiState.value = UiState.Loading  // Show loading
        
        useCase()
            .onSuccess { data -> _uiState.value = UiState.Success(data) }
            .onFailure { error -> _uiState.value = UiState.Error(...) }
    }
}

Error Handling

.onFailure { error ->
    _uiState.value = UiState.Error(
        message = error.localizedMessage ?: "Error occurred",
        exception = error
    )
}

Empty State

.onSuccess { products ->
    _uiState.value = if (products.isEmpty()) {
        UiState.Empty
    } else {
        UiState.Success(products)
    }
}

Summary

MVVM in TecMeli

  • View: Jetpack Compose screens that observe state
  • ViewModel: Manages StateFlow, invokes use cases, handles lifecycle
  • Model: Domain models from Clean Architecture
  • State: UiState sealed class for type-safe state management
  • Flow: Unidirectional data flow from user action to UI update
  • Testing: Easy to test with mocked dependencies

Clean Architecture

Understand how MVVM fits into Clean Architecture

Dependency Injection

Learn how Hilt provides ViewModel dependencies

Build docs developers (and LLMs) love