Design an E-commerce Product Listing
1. Understanding the Problem
Design the Android client for the product listing flow of an e-commerce app like Flipkart, Amazon, or Meesho โ covering the home feed, category browsing, search with filters, product detail page (PDP), cart, and wishlist. The defining challenges are heterogeneous UI layouts (banners, carousels, grids), instant search with debounce, multi-select filters, and optimistic cart updates with offline queuing.
Top e-commerce apps use Server-Driven UI (SDUI): the server returns a JSON layout descriptor that the client renders generically. This lets the backend A/B test new home screen widgets, banners, and carousels without an app release. The client maps component types to ViewHolders โ it never hard-codes the home page structure.
Learn This Pattern โโ Functional Requirements
- Home feed: banners, deal carousels, category grid (SDUI)
- Category / search listing: paginated product grid
- Filters: price range, brand, rating, discount โ multi-select
- Sort: relevance, price asc/desc, popularity
- Product Detail Page (PDP): images, specs, reviews, variants
- Add to cart / wishlist with optimistic update
- Cart badge count live in toolbar
- Search with suggestions and history
โ๏ธ Non-Functional Requirements
- Listing load: <800 ms on 4G
- Search response: suggestions within 200 ms of debounce
- Images: Thumbnail first, full-res on expand
- Scroll: 60 fps on a 2-column product grid
- Offline: Wishlist and cart readable without network
- Cart sync: Queue offline add-to-cart, sync on reconnect
- Price freshness: PDP re-fetches price on resume (flash sales)
- Is the home feed server-driven or hardcoded? (SDUI vs fixed layout)
- Is search client-side (local Room) or server-side (Elasticsearch)?
- Should cart/wishlist work offline?
- Is checkout / payment in scope?
2. The Set Up
The Four Key Screens
Core Components
3. High-Level Design
Server-Driven UI (SDUI) โ Home Feed Response
// Server returns a list of typed components โ client renders generically
{
"components": [
{ "type": "BANNER_CAROUSEL", "items": [{ "imageUrl": "...", "deepLink": "app://sale/diwali" }] },
{ "type": "SECTION_HEADER", "title": "Deal of the Day", "timer": 1714291200 },
{ "type": "PRODUCT_HORIZONTAL_SCROLL", "products": [ ... ] },
{ "type": "CATEGORY_GRID", "columns": 4, "categories": [ ... ] },
{ "type": "PRODUCT_GRID", "title": "Recommended for You", "products": [ ... ] }
]
}
The client's HomeAdapter maps each type string to a distinct ViewHolder. New component types released by the server gracefully degrade if the client doesn't know them โ handled with an UNKNOWN fallback ViewHolder that renders nothing.
Listing Filter State
Filters are composable and cumulative. Model them as a sealed class or a data class that the ViewModel holds as a StateFlow. Every filter change creates a new FilterState and triggers a Paging 3 refresh:
data class FilterState(
val query: String = "",
val sortBy: SortOption = SortOption.RELEVANCE,
val priceMin: Int? = null,
val priceMax: Int? = null,
val brands: Set<String> = emptySet(),
val minRating: Float? = null,
val discountMin: Int? = null
)
// In ViewModel: any filter change resets pagination
val filterState = MutableStateFlow(FilterState())
val products = filterState
.debounce(300) // wait for user to finish typing/selecting
.flatMapLatest { filters ->
Pager(PagingConfig(pageSize = 20)) {
ProductPagingSource(api, filters)
}.flow
}
.cachedIn(viewModelScope)
4. Low-Level Design
Whiteboard: Product Listing Full Pipeline
Flow 1: Search with Debounce + Suggestions
| SearchBar (UI) | SearchViewModel | Room (search_history) | Search API |
|---|---|---|---|
| 1User types "nikes" (typo) โ each keystroke emitted to ViewModel | |||
2queryFlow.debounce(300ms) โ waits for typing to pause |
|||
3Query both sources in parallel via combine() |
|||
4SELECT * FROM search_history WHERE query LIKE 'nike%' LIMIT 3 |
|||
5GET /suggest?q=nikes โ ["nike shoes", "nike air max"] (with fuzzy match) |
|||
| 6Merge: history (labelled ๐) appears first, then server suggestions | |||
| 7Suggestion dropdown shown. User taps "nike air max" | |||
8INSERT "nike air max" into search_history (upsert with timestamp) |
|||
9filterState.update(query="nike air max") โ Paging refresh |
Flow 2: Add to Cart (Optimistic + Offline Queue)
| PDP / Listing UI | CartViewModel | Room (cart table) | Cart API | WorkManager |
|---|---|---|---|---|
| 1User taps "Add to Cart" โ button shows spinner momentarily | ||||
| 2Optimistic: immediately increment cart badge count in StateFlow | ||||
3INSERT into cart (productId, qty, addedAt, syncStatus=PENDING) |
||||
| 4Cart badge updates reactively from Room Flow โ user sees it immediately | ||||
| 5a โ Online: POST /cart {productId, qty} โ success | ||||
| 5b โUPDATE syncStatus=SYNCED | ||||
| 6a โ Offline: POST fails with IOException | ||||
| 6bEnqueue SyncCartWorker (CONNECTED constraint) โ retries when online | ||||
| 7 โ Server error: rollback โ DELETE from Room, decrement badge, show Snackbar |
Flow 3: Product Detail Page (PDP) Load
| PdpFragment | PdpViewModel | OkHttp Cache / Room | Product API |
|---|---|---|---|
| 1User taps product card โ navigate with productId | |||
2Check OkHttp disk cache for /product/{id} |
|||
| 3Cache hit: return stale data immediately (max-age=5min) | |||
| 4Render product immediately from cache โ images, title, specs | |||
| 5Background revalidation: GET /product/{id} with If-None-Match ETag | |||
| 6Fresh data arrives โ update only the price/stock badge (not full reload) | |||
| 7 onResume: Always re-fetch price + stock (flash sales change price every minute) | |||
| 8Progressive image: thumbnail loads first, full-res replaces on complete |
Key Code: SDUI Home Adapter
// One adapter handles all component types โ new types from server need no app update
class HomeAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items = mutableListOf<HomeComponent>()
override fun getItemViewType(position: Int) = when (items[position]) {
is HomeComponent.BannerCarousel -> VIEW_TYPE_BANNER
is HomeComponent.SectionHeader -> VIEW_TYPE_HEADER
is HomeComponent.ProductHorizontalScroll -> VIEW_TYPE_H_SCROLL
is HomeComponent.CategoryGrid -> VIEW_TYPE_CAT_GRID
is HomeComponent.ProductGrid -> VIEW_TYPE_PROD_GRID
is HomeComponent.Unknown -> VIEW_TYPE_UNKNOWN
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
VIEW_TYPE_BANNER -> BannerCarouselVH.from(parent)
VIEW_TYPE_H_SCROLL -> HorizontalScrollVH.from(parent)
VIEW_TYPE_CAT_GRID -> CategoryGridVH.from(parent)
VIEW_TYPE_PROD_GRID -> ProductGridVH.from(parent)
else -> UnknownVH.from(parent) // graceful degradation
}
}
// JSON deserialization: polymorphic based on "type" field
sealed class HomeComponent {
data class BannerCarousel(val items: List<BannerItem>) : HomeComponent()
data class SectionHeader(val title: String, val timerEpoch: Long?) : HomeComponent()
data class ProductHorizontalScroll(val products: List<Product>) : HomeComponent()
data class ProductGrid(val title: String, val products: List<Product>) : HomeComponent()
object Unknown : HomeComponent() // unknown server type โ render nothing
}
Key Code: ProductPagingSource with Filters
class ProductPagingSource(
private val api: ProductApi,
private val filters: FilterState
) : PagingSource<Int, Product>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
val page = params.key ?: 0
return try {
val response = api.getProducts(
query = filters.query,
sortBy = filters.sortBy.apiKey,
priceMin = filters.priceMin,
priceMax = filters.priceMax,
brands = filters.brands.joinToString(","),
minRating = filters.minRating,
page = page,
size = params.loadSize
)
LoadResult.Page(
data = response.products,
prevKey = if (page == 0) null else page - 1,
nextKey = if (response.hasMore) page + 1 else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
// Invalidate when filter changes โ Paging re-creates the source
override fun getRefreshKey(state: PagingState<Int, Product>) = null
}
5. Potential Deep Dives
Product Image โ Progressive Loading
Product images follow a two-phase load: (1) Tiny thumbnail (e.g., 20ร20 blurred placeholder from the API response as a base64 string) renders instantly; (2) The full product image loads via Coil and cross-fades in when ready. On the PDP image gallery, pre-fetch adjacent images (left/right) while the user views the current one using headless Coil requests. Use a ViewPager2 with offscreenPageLimit = 1 so adjacent pages stay in memory.
Serve product details from OkHttp disk cache instantly, then silently refresh in the background. The user sees content immediately. The UI only updates the price/stock badge when fresh data arrives โ not the whole page โ preventing a jarring full reload.
Learn This Pattern โCart Badge โ Global State
The cart item count must be visible across all screens (toolbar badge). Model it as a SharedViewModel scoped to the Activity, not individual Fragments. The CartViewModel exposes a cartCount: StateFlow<Int> derived from Room.observeCartCount(). Every Fragment accesses it via activityViewModels<CartViewModel>(). The badge auto-updates across the whole app when any screen adds/removes a cart item โ zero event bus or broadcast receiver needed.
Filter Bottom Sheet
Use a BottomSheetDialogFragment with a custom layout for multi-select brand chips, a RangeSlider for price, and star rating buttons. When the user taps "Apply", build a new FilterState and pass it back via a setFragmentResult. The ListingFragment receives it and calls listingViewModel.applyFilters(newState), which updates the filterState Flow, triggering flatMapLatest to cancel the old Pager and start fresh. Show a filter badge count on the filter button (e.g., "Filter (3)") to indicate active filters.
Deep Link Navigation
E-commerce apps receive deep links from notifications, WhatsApp shares, and web browsers. Example: app://product/B08P3LGQKR or https://store.com/product/B08P3LGQKR. Configure these in the Manifest with <intent-filter> and <nav-graph> deep links. The Navigation Component resolves the deep link to the correct Fragment with the correct arguments. Handle the case where the user is not logged in โ redirect to login first, then return to the deep-linked product after authentication.
Recently Viewed Products
Store recently viewed product IDs in Room's recently_viewed table (capped at 20 rows via a trigger or manual eviction). On the Home screen, include a "Recently Viewed" horizontal scroll section that reads from Room reactively. Update on every PDP open: INSERT OR REPLACE INTO recently_viewed (id, viewedAt) VALUES (?, ?). This feature works completely offline.
6. What is Expected at Each Level
- RecyclerView with product grid (2 columns)
- Loads images with Coil/Glide
- Basic search with API call on text change
- Cart stored locally in Room
- Handles config changes in ViewModel
- Basic filter passed as query params
- SDUI home feed with unknown type fallback
- Paging 3 + flatMapLatest for filter-aware pagination
- Search debounce + merge local history + server
- Optimistic cart with rollback on error
- WorkManager for offline cart sync queue
- Stale-while-revalidate for PDP
- Cart badge as Activity-scoped SharedViewModel
- Full SDUI rendering engine with versioning
- A/B testing home layouts via server config
- Client-side price update via SSE (flash sale)
- Product image CDN with blurhash thumbnails
- Search ranking: pinned results, sponsored items
- Analytics instrumentation: product impressions, add-to-cart funnel
- Accessibility: content descriptions, custom touch targets for price
7. Interview Questions
SDUI means the server returns a layout descriptor (JSON with component types and data) rather than just raw data. The app renders components dynamically based on the type field. Benefits: (1) No app update needed to change the home page layout โ add a new banner, reorder sections, or run a seasonal sale layout by changing only the server response; (2) A/B testing: serve different layouts to different user segments; (3) Personalisation: the home feed can be different per user based on purchase history. The client's responsibility is rendering any valid component type correctly and gracefully degrading unknown types.
During JSON deserialization, use a polymorphic deserializer (e.g., Gson's RuntimeTypeAdapterFactory or Kotlin Serialization's @JsonClassDiscriminator). When the type field is unrecognised, map it to an Unknown sealed class variant. In the adapter's getItemViewType, the Unknown variant returns a special view type. The UnknownViewHolder inflates a View.GONE layout โ it renders nothing, occupying zero height. This prevents a crash while leaving a no-op placeholder. Alternatively, use @JsonAdapter with a fallback null and filter nulls before binding.
flatMapLatest cancels the previous coroutine/flow when a new value arrives on the upstream flow. If the user changes the sort from "Price: Low to High" to "Popularity" while a page fetch is in-flight, flatMapLatest cancels the old Pager and its pending HTTP requests, then starts a fresh Pager with the new filter. Using flatMap (without Latest) would let both Pagers run concurrently โ the results of the old filter could arrive after the new filter's results and overwrite them, showing the wrong products. Combined with debounce on the filter flow, this ensures only one API call fires per user interaction.
Expose the search text as a MutableStateFlow<String> in the ViewModel. In the Fragment, collect text changes from the SearchView or EditText and call viewModel.onQueryChanged(text). In the ViewModel: queryFlow.debounce(300).distinctUntilChanged().flatMapLatest { query -> searchApi.suggest(query) }. debounce(300) waits 300 ms after the last keystroke before emitting. distinctUntilChanged() skips re-running if the user typed then deleted the same letter. flatMapLatest cancels the in-flight suggestion API call if the user types again before it completes.
Scope a CartViewModel to the Activity (not individual Fragments): val cartVm: CartViewModel by activityViewModels(). It exposes a cartCount: StateFlow<Int> backed by a Room query: SELECT COUNT(*) FROM cart returning a Flow<Int>. Every Fragment and the Activity's toolbar observe this same Flow. When any Fragment adds/removes a cart item and Room updates, the Flow emits and every observer receives the new count simultaneously โ no event bus, no broadcast, no manual notification. The ViewModel survives Fragment transactions and navigation, keeping state consistent throughout the session.
On "Add to Cart" tap: (1) Immediately INSERT the item into Room with syncStatus=PENDING; (2) The cart badge count updates reactively from Room โ user sees it increment instantly; (3) Fire POST /cart in a background coroutine. On API success: UPDATE syncStatus=SYNCED. On API failure (4xx/network error): DELETE the item from Room โ the badge decrements back, and show a Snackbar "Failed to add to cart. Retry?". For offline: keep the PENDING row in Room and enqueue a WorkManager job. The key insight is that the source of truth for the count is always Room โ the API just keeps the server in sync.
Use StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) for product grids where image heights vary (portrait vs landscape products). For uniform-height cards, use GridLayoutManager(context, 2). With Paging 3, the PagingDataAdapter works with both. For the header row (filter bar, sort chips), set the header ViewHolder to span both columns: in GridLayoutManager, override setSpanSizeLookup returning 2 for the header position. In StaggeredGrid, call params.isFullSpan = true in onBindViewHolder for header items.
Three layers: (1) onResume re-fetch: the PDP ViewModel calls fetchLivePrice(productId) every time the screen is resumed. This covers users who left the PDP and came back. (2) Short max-age: set Cache-Control: max-age=60 on the price endpoint โ OkHttp serves cache for 60 s, then revalidates. (3) SSE push (advanced): for true flash sales, the server sends a price update via Server-Sent Events on the /product/{id}/live endpoint โ the client updates the price badge in real-time. On "Add to Cart", always hit the price API one last time to confirm the current price and show a "Price changed" dialog if it differs from what the user saw.
Use a ViewPager2 for the image gallery (swipe between images). For pinch-to-zoom within each image, use PhotoView library (wraps ImageView with matrix-based pan/zoom) or implement a custom ScaleGestureDetector. Double-tap toggles between fit-to-screen and 2x zoom. The zoomed ImageView intercepts touch events, so configure viewPager2.isUserInputEnabled = false when the image is zoomed (zoom > 1.0) to prevent accidental page swipes while panning. When zoom resets to 1.0, re-enable swipe.
Use RecyclerView.addOnScrollListener to detect when items become visible. Alternatively, attach a VisibilityTracker that uses each ViewHolder's itemView.getGlobalVisibleRect() to compute visibility percentage on scroll settle. An item is "impressed" when โฅ50% visible for โฅ1 second. Collect impression events in a batch list in the ViewModel. Flush the batch to analytics API every 30 s or on Fragment.onStop. Don't fire one API call per impression โ batch them: POST /impressions [{productId, position, rankScore, timestamp}]. This data feeds the search ranking and recommendation models server-side.
When the user wishlists an out-of-stock product, send POST /wishlist {productId, notifyOnRestock: true}. The server subscribes the user's FCM token to the product's restock topic. When stock is replenished, the server sends an FCM data message with {type: "RESTOCK", productId: "XYZ", productName: "Nike Air Max"}. The FirebaseMessagingService.onMessageReceived() handler builds and shows a notification with a PendingIntent deep-linking to the PDP. On the client Room side, update the product's inStock flag so the Wishlist screen shows updated availability without an extra API call.
The product API returns a variants map: {size: [S, M, L, XL], colour: [Red, Blue]} with a variantMatrix mapping combinations to availability and price. Render each dimension as a horizontally scrollable chip group. Selected chips are highlighted. On selection, look up the combination in the variant matrix to: (1) update the displayed price; (2) grey out unavailable combinations; (3) update the product image if colour changed (each colour has a separate image array); (4) update the skuId passed to the cart API. If a combination is out of stock, show "Notify Me" instead of "Add to Cart".
Configure OkHttp with a disk cache: OkHttpClient.Builder().cache(Cache(cacheDir, 20L * 1024 * 1024)).build(). The server sets Cache-Control: max-age=300 on product listing responses. OkHttp stores the response and serves it from disk for 5 minutes without hitting the network. When the user navigates back to the same category, the listing appears instantly. After max-age expires, OkHttp revalidates using the ETag header โ if unchanged, the server returns 304 and OkHttp serves from cache again. This is the HTTP-layer equivalent of offline-first for read-only data where Room isn't needed.
Model button state as a sealed class: CartButtonState { Idle | Loading | InCart | Error }. On tap: set state to Loading โ disable button, show spinner. On success: set to InCart โ show "Go to Cart" with green background. On error: set to Error โ show "Retry" with red background, re-enable button. The ViewModel exposes this as a StateFlow<CartButtonState>. Because we write to Room optimistically before the API call, the cart badge updates immediately regardless of the button state. The button state only reflects the API sync status โ it's a cosmetic indicator, not the source of truth for cart contents.
Use ConcatAdapter merging three adapters: (1) SortFilterAdapter โ a single-item adapter showing the sort bar and active filter chips; (2) PagingDataAdapter โ the infinite product grid; (3) LoadStateAdapter โ the loading footer. Set GridLayoutManager.SpanSizeLookup: the header spans all 2 columns (spanSize=2), product cards span 1 each. When filter chips are tapped, update FilterState in the ViewModel โ flatMapLatest creates a new PagingDataAdapter source โ the RecyclerView resets to the top smoothly. The header adapter is not recreated โ only the product adapter refreshes.
Use Paging 3's TestPager: val pager = TestPager(PagingConfig(pageSize=20), ProductPagingSource(fakeApi, filters)). Call pager.refresh() and assert the returned LoadResult.Page contains the expected products. Call pager.append() to test pagination. For the filter integration test: create a FakeProductApi that checks the query parameters match the FilterState. Test error paths by having the fake API throw an exception and assert LoadResult.Error. For the ViewModel's flatMapLatest: use Turbine to collect Flow emissions and assert the correct PagingData is emitted after each filter change.
A comparison tray is a global floating widget visible across screens. Model the comparison list as an Activity-scoped CompareViewModel with a StateFlow<List<Product>> (max 3 items). Each PDP has a "Compare" checkbox that writes to/from this ViewModel. A persistent bottom tray Fragment (added to the Activity layout, not per-Fragment) observes the CompareViewModel and shows thumbnails of selected products. On "Compare Now": navigate to a dedicated CompareFragment that renders a scrollable table mapping spec names โ values across the selected products. Specs are fetched in parallel with async { api.getProduct(id) }.await() for each product.
The search API has rate limits because Elasticsearch queries are expensive. Defense layers: (1) Debounce (already in place) โ 300 ms delay prevents keystroke-per-request; (2) OkHttp Authenticator / Interceptor: on 429, read the Retry-After header and delay the retry accordingly; (3) OkHttp cache: for the same query + filters, serve from cache for 60 s before re-hitting the server; (4) Exponential backoff in Retrofit: on 429, use Kotlin's delay() before retry. For the PagingSource: return LoadResult.Error on 429 โ Paging shows the retry button via LoadStateAdapter, letting the user manually retry rather than hammering the server.
Send the user's auth token with GET /home. The server's personalisation engine uses purchase history, browsing history, and segment data to build the SDUI response with personalised product IDs and section ordering. The client renders it generically โ it doesn't know it's personalised. For cache strategy: personalised home pages must have a user-specific cache key (include user ID in the URL or cache key), so user A's cached home page isn't served to user B on a shared device. Set a shorter max-age (e.g., 5 minutes) for personalised responses vs 30 minutes for non-personalised category pages.
Key accessibility requirements for product cards: (1) Content description: imageView.contentDescription = "Nike Air Max, โน5,999, 4.3 stars, 30% off" โ merge all card info into one meaningful description so TalkBack reads it in one swipe rather than announcing each element separately; (2) Import for accessibility: set card.importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES; (3) Minimum touch target: 48dp ร 48dp for Add to Cart and Wishlist buttons โ use TouchDelegate or padding to expand small icons; (4) Price changes: use ViewCompat.setAccessibilityLiveRegion(priceView, ACCESSIBILITY_LIVE_REGION_POLITE) so TalkBack announces price updates without interrupting ongoing speech.