Design an E-Commerce Platform (Amazon/Flipkart)
An e-commerce platform is a composition of loosely-coupled services — catalog, inventory, orders, payments, and recommendations — each with distinct scaling and consistency requirements. The hardest problems are preventing overselling under concurrent load, processing orders as a distributed transaction across services without a two-phase commit, and making 300M products searchable in under 100ms.
Design it yourself
Don't just read it — drag components onto a canvas and get Aria's interviewer review.
Requirements
Functional
- Product catalog: browse, search, and view product detail pages
- Shopping cart: add/remove items, persist across sessions
- Checkout: place an order, which reserves inventory and initiates payment
- Payment: process credit/debit cards and digital wallets (Stripe/Razorpay integration)
- Order management: track order status (placed → confirmed → shipped → delivered)
- Inventory management: sellers can update stock levels
- Recommendations: personalised product suggestions on home and product pages
- Flash sales: time-limited deals at heavily discounted prices with limited units
Non-Functional
- 300M products in the catalog
- 100K orders/day at steady state; 500K orders/day peak (sale events)
- 99.99% availability for the checkout and payment flows
- Search results returned in < 100ms
- Inventory must never go negative — zero overselling tolerance
- Order placement latency < 500ms (excluding payment processing)
- Payment processing: eventual confirmation within 30 seconds
Capacity Estimation
| Products in catalog | 300M |
| Orders (steady state) | 100K / day ≈ 1.2 / sec |
| Orders (peak) | 500K / day ≈ 6 / sec |
| Flash sale peak | ~10K orders / min ≈ 167 / sec (for 10 min) |
| Catalog reads vs writes | 100:1 (mostly browsing) |
| Storage per product | ~5 KB (metadata + images) → 300M × 5KB = 1.5 TB catalog DB |
| Elasticsearch index size | ~300M docs × ~2KB = ~600 GB index |
| Cart sessions (active) | ~5M concurrent carts in Redis |
High-Level Components
API Gateway / Load Balancer
Routes traffic to downstream microservices. Handles TLS termination, JWT authentication, and per-user rate limiting. Applies aggressive rate limits during flash sales to prevent stampede.
Product Catalog Service
Stores structured product data (title, description, attributes, images, price) in a relational DB (PostgreSQL) with read replicas. Publishes ProductUpdated events to Kafka which feed the Elasticsearch index and CDN-cached product pages.
Search Service (Elasticsearch)
Full-text and faceted search over 300M products. Supports filtering by category, price range, rating, and availability. Updated asynchronously via a Kafka consumer. Returns results in < 100ms for 95% of queries.
Inventory Service
The single source of truth for stock levels. Exposes reserve and release operations with optimistic locking. Uses Redis atomic operations for real-time stock counters during flash sales.
Cart Service
Maintains shopping cart state (userId → list of {productId, quantity, price snapshot}) in Redis with a 30-day TTL. Validates item availability on checkout but does NOT reserve inventory — reservation happens only at order placement.
Order Service
Orchestrates the checkout flow as a Saga. Creates an order record, triggers inventory reservation, triggers payment, and transitions order state. Handles compensation (inventory release, refund) if any step fails.
Payment Service
Wraps the payment gateway (Stripe/Razorpay). Accepts payment initiation, polls or receives webhooks for payment confirmation, and emits PaymentCompleted or PaymentFailed events to Kafka.
Notification Service
Consumes order and payment events from Kafka and sends email/SMS/push notifications to buyers and sellers. Fully decoupled from the critical path.
Recommendation Engine
Generates personalised product rankings based on browsing history, purchase history, and co-purchase signals. Pre-computes results in a nightly batch job and serves them via Redis for home-screen and product-page requests.
Architecture Diagram
Deep Dives
Inventory Consistency — Preventing Overselling
Overselling (selling more units than available) is catastrophic for seller trust and causes costly cancellations. Three concurrent orders for the last unit in stock must result in exactly one success.
Approach 1: Pessimistic locking (SELECT FOR UPDATE) ```sql BEGIN; SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE; -- Check stock > 0 UPDATE inventory SET stock = stock - qty WHERE product_id = ?; COMMIT; ``` Correct, but serialises all updates to a product. Under high concurrency (flash sale: 1000 orders/sec for one SKU), every transaction waits for the lock. Throughput collapses.
Approach 2: Optimistic locking (version column) ```sql UPDATE inventory SET stock = stock - qty, version = version + 1 WHERE product_id = ? AND version = ? AND stock >= qty; -- rows_affected == 0 → conflict, retry ``` No lock held. Concurrent updates fail-fast and retry. Works well when contention is low (< 20% retry rate). At flash-sale contention levels, retry storms degrade throughput.
Approach 3: Redis atomic decrement (recommended for flash sales) Pre-load stock into Redis (`stock:{productId}` = integer). Use `DECRBY` which is atomic in Redis: ``` if (DECRBY stock:{productId} qty) >= 0 → success else INCRBY stock:{productId} qty → rollback ``` Redis single-threaded command processing ensures atomicity without locking. A Lua script makes the check-and-decrement truly atomic: ```lua local stock = tonumber(redis.call('GET', KEYS[1])) if stock >= tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) else return -1 end ``` The DB remains the authoritative store — Redis is the fast reservation gate. A background reconciliation job periodically syncs Redis counters with DB stock levels.
Java — Atomic inventory reservation via Redis Lua script with DB fallback
// Inventory Service — Redis-based atomic reservation with DB persistence
@Service
public class InventoryService {
private final RedisTemplate<String, String> redis;
private final InventoryRepository inventoryRepo;
private final DefaultRedisScript<Long> reserveScript;
@PostConstruct
public void initScript() {
// Atomic check-and-decrement Lua script
String lua = """
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then return -2 end -- key not loaded
if stock >= tonumber(ARGV[1]) then
return redis.call('DECRBY', KEYS[1], ARGV[1])
else
return -1 -- insufficient stock
end
""";
reserveScript = new DefaultRedisScript<>(lua, Long.class);
}
/**
* Reserve qty units. Returns the new stock level, or throws if unavailable.
*/
public long reserve(long productId, int qty) {
String key = "stock:" + productId;
Long result = redis.execute(reserveScript,
List.of(key), String.valueOf(qty));
if (result == null || result == -2) {
// Redis key not loaded — fall back to DB with optimistic lock
return reserveInDb(productId, qty);
}
if (result < 0) {
throw new InsufficientStockException(productId, qty);
}
// Persist reservation asynchronously to DB
inventoryRepo.decrementAsync(productId, qty);
return result;
}
public void release(long productId, int qty) {
String key = "stock:" + productId;
redis.opsForValue().increment(key, qty);
inventoryRepo.incrementAsync(productId, qty);
}
private long reserveInDb(long productId, int qty) {
int updated = inventoryRepo.tryReserve(productId, qty);
if (updated == 0) throw new InsufficientStockException(productId, qty);
return inventoryRepo.getStock(productId);
}
}Order Processing — Saga Pattern
Placing an order touches three services: Inventory, Payment, and Order. A traditional distributed two-phase commit (2PC) is fragile and blocks resources across services. The Saga pattern breaks the transaction into a sequence of local transactions, each publishing an event or message to trigger the next step. If any step fails, compensating transactions undo completed steps.
Choreography-based Saga (event-driven): - Each service reacts to events and publishes its own outcome event - No central coordinator - Hard to track state; can be difficult to debug
Orchestration-based Saga (recommended for checkout): - A central Order Service orchestrates each step by sending commands - Knows the full state machine and handles failures explicitly - Easier to monitor and debug (all saga state is in the Order record)
Checkout Saga steps: ``` 1. Create order record (status: PENDING) 2. Reserve inventory → success → continue → failure → mark order FAILED (no compensation needed yet) 3. Initiate payment → success → continue → failure → release inventory (compensation) → mark FAILED 4. Confirm order → mark CONFIRMED, notify seller + buyer ```
Idempotency: Every step must be idempotent. Payment initiation uses an idempotency key (order_id) so that retries after a timeout never double-charge. Inventory reservation uses order_id as a reservation_id, so re-sending the command is a no-op if already reserved.
Timeout handling: The Order Service uses a scheduled job to detect orders stuck in PENDING for > 5 minutes and trigger cancellation compensation.
Java — Orchestration-based Order Saga with inventory compensation
// Order Service — orchestration-based Saga
@Service
public class OrderSagaOrchestrator {
private final OrderRepository orderRepo;
private final InventoryServiceClient inventoryClient;
private final PaymentServiceClient paymentClient;
private final KafkaTemplate<String, Object> kafka;
@Transactional
public Order placeOrder(PlaceOrderRequest req) {
// Step 1: Persist order in PENDING state
Order order = orderRepo.save(Order.builder()
.userId(req.userId())
.items(req.items())
.totalAmount(req.totalAmount())
.status(OrderStatus.PENDING)
.build());
try {
// Step 2: Reserve inventory (idempotent by orderId)
for (OrderItem item : order.items()) {
inventoryClient.reserve(
new ReserveRequest(item.productId(), item.qty(), order.id()));
}
order.setStatus(OrderStatus.INVENTORY_RESERVED);
orderRepo.save(order);
} catch (InsufficientStockException e) {
order.setStatus(OrderStatus.FAILED);
order.setFailureReason("Insufficient stock: " + e.getMessage());
orderRepo.save(order);
throw e;
}
try {
// Step 3: Initiate payment (async — confirmation via webhook)
paymentClient.initiate(
new PaymentRequest(order.id(), order.totalAmount(),
req.paymentMethodToken()));
order.setStatus(OrderStatus.PAYMENT_PENDING);
orderRepo.save(order);
} catch (PaymentException e) {
// Compensation: release inventory
for (OrderItem item : order.items()) {
inventoryClient.release(
new ReleaseRequest(item.productId(), item.qty(), order.id()));
}
order.setStatus(OrderStatus.FAILED);
order.setFailureReason("Payment initiation failed");
orderRepo.save(order);
throw e;
}
return order;
}
// Called when Payment Service webhook confirms payment
@KafkaListener(topics = "payment-events")
public void onPaymentEvent(PaymentEvent event) {
Order order = orderRepo.findById(event.orderId()).orElseThrow();
if (event.status() == PaymentStatus.COMPLETED) {
order.setStatus(OrderStatus.CONFIRMED);
kafka.send("order-events", new OrderConfirmedEvent(order.id(), order.userId()));
} else {
// Payment failed — release inventory
for (OrderItem item : order.items()) {
inventoryClient.release(
new ReleaseRequest(item.productId(), item.qty(), order.id()));
}
order.setStatus(OrderStatus.FAILED);
order.setFailureReason("Payment declined");
}
orderRepo.save(order);
}
}Search Architecture with Elasticsearch
The product catalog search must handle: - Full-text: "wireless noise-cancelling headphones" - Faceted filtering: category = Electronics, price = $50-$200, brand = Sony - Sorting: by relevance score, price ascending, average rating - Autocomplete: real-time query suggestions as the user types - Scale: 300M documents, < 100ms p95 response
Index design: Each product document in Elasticsearch contains fields used for both search and filtering: ```json { "product_id": 12345, "title": "Sony WH-1000XM5 Wireless Headphones", "description": "...", "brand": "Sony", "category_path": ["Electronics", "Audio", "Headphones"], "price": 349.99, "avg_rating": 4.7, "in_stock": true, "attributes": { "color": "Black", "connectivity": "Bluetooth" } } ```
Sharding strategy: - 300M documents across 30 primary shards (10M docs/shard, each ~20 GB) - 1 replica shard per primary for redundancy and read parallelism - Total: 60 shards, 1.2 TB index storage
Sync from catalog DB: - Debezium CDC captures changes from the PostgreSQL `products` table - Publishes to Kafka `product-updates` topic - Elasticsearch indexer consumer batch-upserts into the index - Lag is typically < 5 seconds — search reflects catalog updates near-instantly
Autocomplete: - A separate lightweight index stores popular search terms - `completion` field type in Elasticsearch for prefix-matching at sub-10ms - Updated with trending searches from real-time query logs
Java — Elasticsearch search with facets, filters, boost, and pagination
// Search Service — Elasticsearch query with facets and pagination
@Service
public class ProductSearchService {
private final ElasticsearchClient esClient;
private static final String INDEX = "products";
public SearchResponse<ProductDoc> search(SearchQuery query) throws IOException {
// Build the bool query
BoolQuery.Builder boolQuery = new BoolQuery.Builder();
// Full-text match on title and description
if (query.text() != null && !query.text().isBlank()) {
boolQuery.must(m -> m.multiMatch(mm -> mm
.fields("title^3", "description", "brand^2") // title boosted 3x
.query(query.text())
.fuzziness("AUTO")
));
}
// Filters (don't affect relevance score)
if (query.category() != null) {
boolQuery.filter(f -> f.term(t -> t
.field("category_path").value(query.category())));
}
if (query.minPrice() != null || query.maxPrice() != null) {
boolQuery.filter(f -> f.range(r -> {
r.field("price");
if (query.minPrice() != null) r.gte(JsonData.of(query.minPrice()));
if (query.maxPrice() != null) r.lte(JsonData.of(query.maxPrice()));
return r;
}));
}
boolQuery.filter(f -> f.term(t -> t.field("in_stock").value(true)));
// Sort
SortOptions sort = switch (query.sortBy()) {
case PRICE_ASC -> SortOptions.of(s -> s.field(f -> f.field("price").order(SortOrder.Asc)));
case RATING -> SortOptions.of(s -> s.field(f -> f.field("avg_rating").order(SortOrder.Desc)));
default -> SortOptions.of(s -> s.score(sc -> sc.order(SortOrder.Desc)));
};
return esClient.search(s -> s
.index(INDEX)
.query(q -> q.bool(boolQuery.build()))
.sort(sort)
.from(query.page() * query.pageSize())
.size(query.pageSize())
// Aggregations for facet counts
.aggregations("by_brand", a -> a.terms(t -> t.field("brand").size(20)))
.aggregations("price_range", a -> a.range(r -> r.field("price")
.ranges(List.of(
AggregationRange.of(ar -> ar.to(50.0)),
AggregationRange.of(ar -> ar.from(50.0).to(200.0)),
AggregationRange.of(ar -> ar.from(200.0))
))
),
ProductDoc.class
);
}
}Flash Sale Handling
A flash sale (e.g. iPhone at 50% off, 500 units, 10 minutes) generates a sudden spike of 10,000+ orders per minute. The normal checkout path will not survive this without specific design.
Problems to solve: 1. Thundering herd on inventory: 10,000 concurrent requests all trying to read/write the same product's stock row 2. Database overload: normal order throughput is 1.2/sec; flash sale drives it to 167/sec 3. Payment gateway rate limits: external payment APIs have their own rate limits 4. Fairness: prevent bots from buying all units before real users see the page
Solution: Queue-based order funnel 1. Pre-load stock to Redis: Before the sale starts, load `flash:{saleId}:stock:{productId}` = 500 into Redis 2. Rate-limited entry: API Gateway caps requests to the flash-sale endpoint at 5000/sec per sale event. Excess requests receive HTTP 429 immediately. 3. Atomic Redis reservation: Each checkout attempt does the Lua check-and-decrement on Redis. 500 succeed; the rest get `out_of_stock` response immediately — no DB write needed for failures 4. Order queue: The 500 successful reservations are enqueued into a Kafka topic `flash-orders` with a per-sale partition 5. Order workers: A pool of workers consumers processes orders sequentially from the partition — persisting to DB, calling the payment gateway. This smooths the write spike. 6. Waitlist display: Users who got a successful Redis reservation but are waiting for order confirmation see a "Payment processing..." screen. If payment fails within 30s, the unit is re-released to a waitlist queue. 7. Bot prevention: Require login, device fingerprinting, and a one-per-account purchase limit enforced in Redis: `SETNX flash:{saleId}:user:{userId} 1`
Java — Flash sale gate: Redis Lua reservation + per-user limit + Kafka queueing
// Flash Sale Service — entry gate with Redis reservation and order queueing
@Service
public class FlashSaleService {
private final RedisTemplate<String, String> redis;
private final DefaultRedisScript<Long> flashReserveScript;
private final KafkaTemplate<String, FlashOrderEvent> kafka;
@PostConstruct
public void initScript() {
String lua = """
-- KEYS[1] = stock key, KEYS[2] = user-purchased key
-- ARGV[1] = qty, ARGV[2] = userId
if redis.call('EXISTS', KEYS[2]) == 1 then
return -3 -- user already purchased
end
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock < tonumber(ARGV[1]) then
return -1 -- out of stock
end
redis.call('SETEX', KEYS[2], 86400, '1') -- mark user as purchased
return redis.call('DECRBY', KEYS[1], ARGV[1])
""";
flashReserveScript = new DefaultRedisScript<>(lua, Long.class);
}
public FlashOrderResponse attemptPurchase(long saleId, long productId,
long userId, int qty) {
String stockKey = "flash:" + saleId + ":stock:" + productId;
String purchasedKey = "flash:" + saleId + ":user:" + userId;
Long result = redis.execute(flashReserveScript,
List.of(stockKey, purchasedKey), String.valueOf(qty), String.valueOf(userId));
if (result == null || result == -1) {
return FlashOrderResponse.outOfStock();
}
if (result == -3) {
return FlashOrderResponse.alreadyPurchased();
}
// Reservation succeeded — enqueue order for async processing
String reservationId = UUID.randomUUID().toString();
kafka.send("flash-orders", String.valueOf(saleId),
new FlashOrderEvent(reservationId, saleId, productId, userId, qty));
return FlashOrderResponse.reserved(reservationId);
}
}Key Trade-offs
Pessimistic locking vs optimistic locking vs Redis for inventory
Pessimistic locking serialises all writes on a product, destroying throughput under flash-sale concurrency. Optimistic locking is fine for < 20% retry rate but generates retry storms under extreme contention. Redis atomic decrement handles thousands of concurrent requests per second for the same SKU without contention.
2PC (two-phase commit) vs Saga for order distributed transaction
2PC requires all participating services to hold locks until the coordinator decides, coupling availability. A Saga uses local transactions with compensating actions — each service commits independently. The Order Service is the sole orchestrator, making failure handling explicit and debuggable.
Synchronous vs asynchronous search index updates
Synchronously writing to Elasticsearch on every catalog update would couple the Catalog Service to Elasticsearch availability and add latency. CDC via Debezium → Kafka decouples the two, allows batch indexing, and naturally handles retries. A 5-second lag in search reflecting a new product is acceptable.
Cart stored in Redis vs database
Cart data is ephemeral — most sessions never convert. A 30-day TTL in Redis is cost-effective and provides sub-millisecond cart reads. If Redis is lost (unlikely with replicas), the user rebuilds their cart — a minor UX inconvenience vs. the cost of storing billions of cart rows in a relational DB.
Monolith vs microservices for initial build
These services have fundamentally different scaling requirements (inventory: high write contention; catalog: read-heavy; payments: strict consistency). Separating them allows independent scaling, isolated deployments, and technology choices per domain (Redis for inventory, PostgreSQL for orders, Elasticsearch for search).
Interview Tips
- 1Lead with the hardest problem: inventory consistency. Describe pessimistic → optimistic → Redis Lua in that order to show your reasoning, not just the answer.
- 2The Saga pattern is expected for distributed checkout. Know the difference between choreography (event-driven) and orchestration (central coordinator). Recommend orchestration for checkout — the failure paths are more explicit.
- 3Flash sale is a classic follow-up. The key insight is: move the reservation check out of the database and into Redis BEFORE the order is created. The DB is the long-tail persistence layer, not the gate.
- 4Elasticsearch for search is standard, but go deeper: explain sharding strategy (30 shards for 300M docs), the CDC sync pipeline, and boosted field weights (title^3 > description).
- 5Always mention idempotency keys for payment. External payment APIs can fail mid-request; retrying without an idempotency key double-charges the customer.
- 6When asked about recommendations, keep it brief: collaborative filtering pre-computed nightly, top-K results cached in Redis per user, lightweight re-ranking at serve time with real-time signals.
- 7For the order state machine question: PENDING → INVENTORY_RESERVED → PAYMENT_PENDING → CONFIRMED → SHIPPED → DELIVERED, with FAILED and CANCELLED as terminal states. Draw this explicitly.
Discussion
Discussion
Sign in to join the discussion.