Intermediate
Transactions & Consistency
30 min

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.

Saga PatternElasticsearchInventoryDistributed TransactionsRedisKafka

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 catalog300M
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 writes100: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

Rendering 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

Redis atomic Lua script for flash sales; optimistic locking for steady state

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

Saga (orchestration pattern)

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

Asynchronous via Kafka CDC (Debezium)

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

Redis with TTL

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

Microservices for catalog, inventory, orders, and payments from the start

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.

Loading discussion…