androidengineers.Book a session

Designing an E-commerce App

Catalog, Search & Facets

article25 minHard

A product catalog with faceted search is one of the most complex UI patterns in e-commerce: it combines real-time search, multi-dimensional filtering, and paginated results that all change together.

Data Model

data class SearchState(
    val query: String = "",
    val filters: SearchFilters = SearchFilters(),
    val sortOrder: SortOrder = SortOrder.RELEVANCE,
    val results: List<Product> = emptyList(),
    val facets: List<Facet> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
    val totalResults: Int = 0
)

data class SearchFilters(
    val categories: Set<String> = emptySet(),
    val priceRange: ClosedRange<Int>? = null,
    val brands: Set<String> = emptySet(),
    val rating: Float? = null,
    val inStockOnly: Boolean = false
)

data class Facet(
    val name: String,         // "Brand", "Category", "Price Range"
    val type: FacetType,
    val values: List<FacetValue>
)

data class FacetValue(
    val id: String,
    val label: String,
    val count: Int,           // number of results if this value is selected
    val isSelected: Boolean
)

enum class FacetType { CHECKBOX, RANGE, RADIO }
enum class SortOrder { RELEVANCE, PRICE_ASC, PRICE_DESC, RATING, NEWEST }

ViewModel: Debounced Search

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val repository: CatalogRepository
) : ViewModel() {

    private val _state = MutableStateFlow(SearchState())
    val state: StateFlow<SearchState> = _state.asStateFlow()

    private val searchTrigger = MutableSharedFlow<Unit>(replay = 1)

    init {
        viewModelScope.launch {
            // Combine query + filter changes, debounce, then search
            combine(
                _state.map { it.query }.distinctUntilChanged(),
                _state.map { it.filters }.distinctUntilChanged(),
                _state.map { it.sortOrder }.distinctUntilChanged()
            ) { _, _, _ -> Unit }
            .debounce(300)  // wait 300ms after last change before searching
            .collect { performSearch() }
        }
    }

    fun onQueryChanged(query: String) {
        _state.update { it.copy(query = query) }
    }

    fun onFilterChanged(filters: SearchFilters) {
        _state.update { it.copy(filters = filters) }
    }

    fun onSortChanged(sort: SortOrder) {
        _state.update { it.copy(sortOrder = sort) }
    }

    fun toggleCategoryFilter(category: String) {
        val current = _state.value.filters.categories
        val updated = if (category in current) current - category else current + category
        onFilterChanged(_state.value.filters.copy(categories = updated))
    }

    private suspend fun performSearch() {
        _state.update { it.copy(isLoading = true, error = null) }
        val s = _state.value

        repository.search(
            query = s.query,
            filters = s.filters,
            sortOrder = s.sortOrder
        ).onSuccess { response ->
            _state.update { it.copy(
                results = response.products,
                facets = response.facets,
                totalResults = response.totalCount,
                isLoading = false
            ) }
        }.onFailure { error ->
            _state.update { it.copy(error = error.message, isLoading = false) }
        }
    }
}

Facet Filter UI

@Composable
fun FacetPanel(facets: List<Facet>, onFilterChanged: (SearchFilters) -> Unit) {
    LazyColumn {
        items(facets) { facet ->
            when (facet.type) {
                FacetType.CHECKBOX -> CheckboxFacet(facet, onFilterChanged)
                FacetType.RANGE -> RangeFacet(facet, onFilterChanged)
                FacetType.RADIO -> RadioFacet(facet, onFilterChanged)
            }
        }
    }
}

@Composable
fun CheckboxFacet(facet: Facet, onChanged: (SearchFilters) -> Unit) {
    Column {
        Text(facet.name, style = MaterialTheme.typography.titleSmall)
        facet.values.forEach { value ->
            Row(
                modifier = Modifier.fillMaxWidth().clickable { /* toggle */ }.padding(8.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Checkbox(
                    checked = value.isSelected,
                    onCheckedChange = { /* toggle value */ }
                )
                Text("${value.label} (${value.count})", modifier = Modifier.weight(1f))
            }
        }
    }
}

Search with Paging 3

For large catalogs, paginate search results:

class CatalogPagingSource(
    private val api: CatalogApi,
    private val query: String,
    private val filters: SearchFilters,
    private val sort: SortOrder
) : PagingSource<Int, Product>() {

    override fun getRefreshKey(state: PagingState<Int, Product>): Int? = null

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> = try {
        val page = params.key ?: 1
        val response = api.search(
            query = query,
            page = page,
            pageSize = params.loadSize,
            filters = filters.toApiParams(),
            sort = sort.name
        )

        LoadResult.Page(
            data = response.products,
            prevKey = if (page == 1) null else page - 1,
            nextKey = if (response.hasNextPage) page + 1 else null
        )
    } catch (e: IOException) {
        LoadResult.Error(e)
    }
}

// Repository creates a new PagingSource when search params change
fun searchPaged(query: String, filters: SearchFilters, sort: SortOrder): Flow<PagingData<Product>> =
    Pager(PagingConfig(pageSize = 20)) {
        CatalogPagingSource(api, query, filters, sort)
    }.flow

Key Takeaways

PatternRule
Debounce query300ms debounce prevents spamming the API on every keystroke
Facets from serverReturn facet counts from the same search endpoint; don't compute client-side
Clear filters = new searchChanging any filter triggers a fresh search with page = 1
Paging for resultsUse Paging 3 for large catalogs; room for > 1000 products
Filter chipsShow active filters as dismissible chips above results
distinctUntilChangedPrevent redundant searches when filters haven't actually changed

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Catalog, Search & Facets | Android System Design | Android Engineers