androidengineers.Book a session

Designing an E-commerce App

Cart & Pricing Consistency

article20 minHard

A shopping cart must stay consistent with the server's state: prices can change, inventory can run out, and discounts can expire. Getting this wrong leads to orders placed at incorrect prices or for out-of-stock items.

Cart Data Model

data class CartState(
    val items: List<CartItem> = emptyList(),
    val subtotal: Money = Money.ZERO,
    val discount: Money = Money.ZERO,
    val tax: Money = Money.ZERO,
    val total: Money = Money.ZERO,
    val validationErrors: List<CartError> = emptyList(),
    val isLoading: Boolean = false
)

data class CartItem(
    val productId: String,
    val variantId: String,
    val name: String,
    val imageUrl: String,
    val quantity: Int,
    val unitPrice: Money,           // price at time of adding
    val currentPrice: Money,        // price as of last server sync
    val maxQuantity: Int,           // current stock
    val isAvailable: Boolean
)

data class Money(val cents: Long, val currencyCode: String = "USD") {
    companion object { val ZERO = Money(0) }
    operator fun plus(other: Money) = Money(cents + other.cents, currencyCode)
    fun format(): String = "$${cents / 100}.${(cents % 100).toString().padStart(2, '0')}"
}

sealed class CartError {
    data class PriceChanged(val productId: String, val oldPrice: Money, val newPrice: Money) : CartError()
    data class OutOfStock(val productId: String, val maxAvailable: Int) : CartError()
    data class DiscountExpired(val code: String) : CartError()
}

Cart Sync Strategy

class CartRepository(
    private val api: CartApi,
    private val localStore: CartLocalStore  // DataStore
) {
    // Local-first: read from local, sync with server
    val cartState: Flow<CartState> = localStore.cart
        .onStart { syncWithServer() }  // sync on first collection

    suspend fun syncWithServer() {
        try {
            val serverCart = api.getCart()
            val localCart = localStore.getCart()

            // Validate prices and availability
            val errors = mutableListOf<CartError>()
            val updatedItems = localCart.items.map { localItem ->
                val serverItem = serverCart.items.find { it.productId == localItem.productId }
                    ?: return@map localItem.copy(isAvailable = false)

                if (serverItem.currentPrice != localItem.currentPrice) {
                    errors.add(CartError.PriceChanged(
                        localItem.productId,
                        oldPrice = localItem.unitPrice,
                        newPrice = serverItem.currentPrice
                    ))
                }

                if (localItem.quantity > serverItem.maxQuantity) {
                    errors.add(CartError.OutOfStock(localItem.productId, serverItem.maxQuantity))
                }

                localItem.copy(
                    currentPrice = serverItem.currentPrice,
                    maxQuantity = serverItem.maxQuantity,
                    isAvailable = serverItem.isAvailable
                )
            }

            localStore.updateCart(localCart.copy(
                items = updatedItems,
                validationErrors = errors
            ))
            recalculateTotals()
        } catch (e: IOException) {
            // Keep local cart; show warning that prices may be outdated
        }
    }

    fun addItem(product: Product, variant: Variant, quantity: Int = 1) {
        val currentItems = localStore.getCart().items.toMutableList()
        val existingIndex = currentItems.indexOfFirst {
            it.productId == product.id && it.variantId == variant.id
        }

        if (existingIndex >= 0) {
            val existing = currentItems[existingIndex]
            val newQty = minOf(existing.quantity + quantity, existing.maxQuantity)
            currentItems[existingIndex] = existing.copy(quantity = newQty)
        } else {
            currentItems.add(CartItem(
                productId = product.id,
                variantId = variant.id,
                name = product.name,
                imageUrl = product.imageUrl,
                quantity = quantity,
                unitPrice = variant.price,
                currentPrice = variant.price,
                maxQuantity = variant.stockCount,
                isAvailable = true
            ))
        }

        localStore.updateItems(currentItems)
        recalculateTotals()
    }

    private fun recalculateTotals() {
        val items = localStore.getCart().items
        val subtotal = items.fold(Money.ZERO) { acc, item ->
            acc + Money(item.currentPrice.cents * item.quantity)
        }
        // Apply discount codes, compute tax server-side
        localStore.updateTotals(subtotal = subtotal)
    }
}

Price Change Alert UI

@Composable
fun CartValidationBanner(errors: List<CartError>, onAcknowledge: () -> Unit) {
    if (errors.isEmpty()) return

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.errorContainer)
            .padding(16.dp)
    ) {
        errors.forEach { error ->
            when (error) {
                is CartError.PriceChanged ->
                    Text("Price for item changed from ${error.oldPrice.format()} to ${error.newPrice.format()}")
                is CartError.OutOfStock ->
                    Text("Only ${error.maxAvailable} left in stock — quantity adjusted")
                is CartError.DiscountExpired ->
                    Text("Discount code '${error.code}' has expired")
            }
        }

        Spacer(Modifier.height(8.dp))
        Button(onClick = onAcknowledge) { Text("I understand, continue") }
    }
}

Quantity Limits Enforcement

@Composable
fun QuantitySelector(
    quantity: Int,
    maxQuantity: Int,
    onQuantityChanged: (Int) -> Unit
) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        IconButton(
            onClick = { onQuantityChanged(quantity - 1) },
            enabled = quantity > 1
        ) {
            Icon(Icons.Default.Remove, "Decrease")
        }

        Text("$quantity", modifier = Modifier.width(32.dp), textAlign = TextAlign.Center)

        IconButton(
            onClick = { onQuantityChanged(quantity + 1) },
            enabled = quantity < maxQuantity   // enforce stock limit
        ) {
            Icon(Icons.Default.Add, "Increase")
        }

        if (quantity >= maxQuantity) {
            Text("(max)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
        }
    }
}

Key Takeaways

PatternRule
Server sync on openAlways validate cart against server before showing totals
Show price changesAlert users to price changes before checkout; don't silently update
Enforce stock limitsNever allow quantity > available stock
Local-first cartRead from local; sync in background; keep UI responsive
Totals server-sideAlways recalculate totals server-side at checkout; client total is display-only
Discount validationValidate discount codes server-side; mark as expired in UI if rejected

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Cart & Pricing Consistency | Android System Design | Android Engineers