System Design: E-Commerce Checkout

System DesignE-CommerceArchitectureBackend
Share on LinkedIn

Black Friday checkout is a distributed systems exam. A user clicks "Place Order" and your system must reserve inventory, charge a payment method, create an order record, trigger fulfillment, and send confirmation — across four services that can each fail independently. If payment succeeds but order creation crashes, you've charged someone without shipping anything. If inventory isn't reserved before payment, you've sold the same last unit to three people.

E-commerce checkout design is about sequencing operations correctly, making every step idempotent, and having compensating actions for every failure path.

Checkout flow overview

Cart → Validate → Reserve Inventory → Process Payment → Create Order → Fulfill → Confirm
                       ↓ (fail)            ↓ (fail)         ↓ (fail)
                  Release (noop)     Release Inventory   Refund + Release

Each arrow is a service call or event. Failures at any point trigger compensating actions for all prior successful steps.

Cart and validation

The cart service holds items client-side (localStorage) or server-side (session/database). On checkout initiation:

async def initiate_checkout(cart_id: str, user_id: str) -> CheckoutSession:
    cart = await cart_service.get(cart_id)
    validate_cart(cart)  # non-empty, valid SKUs, quantities > 0

    prices = await pricing_service.get_current_prices(cart.items)
    shipping = await shipping_service.estimate(cart.items, user.address)
    tax = await tax_service.calculate(cart.items, user.address)

    session = CheckoutSession(
        id=generate_id(),
        cart=cart, prices=prices, shipping=shipping, tax=tax,
        status="pending", expires_at=now() + timedelta(minutes=30)
    )
    await checkout_store.save(session)
    return session

Prices are fetched at checkout time, not cart-add time — prevents stale pricing exploits. The checkout session has a TTL; expired sessions release any reservations.

Inventory reservation

Reserve before payment, not after:

-- Atomic reservation — fails if insufficient stock
UPDATE inventory
SET reserved = reserved + :qty, available = available - :qty
WHERE sku = :sku AND available >= :qty;

-- Check rows affected; 0 means out of stock
async def reserve_inventory(session: CheckoutSession) -> ReservationResult:
    reservations = []
    for item in session.cart.items:
        success = await inventory_service.reserve(
            sku=item.sku, qty=item.quantity,
            reservation_id=session.id, ttl=1800
        )
        if not success:
            await release_all(reservations)
            return ReservationResult(success=False, reason="out_of_stock")
        reservations.append(item.sku)
    return ReservationResult(success=True, reservations=reservations)

Reservations expire after 30 minutes via a background job that releases stale holds. This prevents abandoned carts from blocking inventory indefinitely.

Payment processing with idempotency

Every payment request carries an idempotency key — typically the checkout session ID:

async def process_payment(session: CheckoutSession, payment_method: str):
    idempotency_key = f"checkout:{session.id}"

    existing = await payment_service.get_by_idempotency_key(idempotency_key)
    if existing:
        return existing  # Already processed — return same result

    result = await payment_service.charge(
        amount=session.total,
        payment_method=payment_method,
        idempotency_key=idempotency_key,
        metadata={"checkout_session_id": session.id}
    )
    return result

Stripe, Adyen, and other processors natively support idempotency keys. If your checkout service retries after a timeout, the payment processor returns the original result instead of charging twice.

Payment states: pending → authorized → captured → (refunded). Authorize during checkout; capture when the order ships (for physical goods) or immediately (for digital goods).

Order creation saga

After successful payment, create the order in a durable, retryable step:

async def complete_checkout(session_id: str):
    session = await checkout_store.get(session_id)

    if session.status == "completed":
        return session.order_id  # Idempotent

    payment = await payment_service.get_by_idempotency_key(f"checkout:{session_id}")
    if payment.status != "captured":
        raise PaymentNotComplete()

    order = await order_service.create(
        user_id=session.user_id,
        items=session.cart.items,
        payment_id=payment.id,
        shipping=session.shipping,
        total=session.total
    )

    await checkout_store.update(session_id, status="completed", order_id=order.id)
    await event_bus.publish("order.created", order)
    return order.id

If order creation fails after payment, a reconciliation job detects orphaned payments (payment captured, no order) and either creates the order retroactively or initiates a refund.

Event-driven fulfillment

Order creation publishes events consumed by downstream services:

Each consumer processes events idempotently — duplicate order.created events (from at-least-once delivery) must not double-ship or double-email.

Handling partial failures

Failure point Compensating action
Validation fails Return error, no side effects
Inventory reservation fails Return "out of stock", no reservation
Payment fails Release inventory reservation
Order creation fails Retry; if persistent, refund payment + release inventory
Fulfillment fails Order exists, payment captured; retry fulfillment, notify support

The saga orchestrator (or choreographed events) tracks which steps completed and executes compensations in reverse order.

Treat production rollout as a measured change: ship with observability, validate rollback, and review metrics 24 hours after deploy — patterns that look obvious in docs fail when skipped under release pressure.

Common production mistakes

Teams get ecommerce checkout wrong in predictable ways:

System design for ecommerce checkout breaks at scale when hot keys, thundering herds, and cache stampedes are discovered during launch week instead of load test week.

Debugging and triage workflow

When ecommerce checkout misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

How do you prevent overselling when multiple users buy the last item?

Use optimistic inventory reservation with atomic decrement: UPDATE inventory SET quantity = quantity - 1 WHERE sku = ? AND quantity > 0. If zero rows affected, the item is out of stock — reject the checkout. Reserve inventory during checkout (with TTL) rather than at payment time, so unpaid carts don't block other buyers. Release reservations that expire after 15-30 minutes.

What happens if payment succeeds but order creation fails?

This is the most critical failure mode. Use idempotency keys on payment requests so retries don't double-charge. After payment confirmation, order creation must be retried until it succeeds — store payment confirmation in a durable queue and process it with at-least-once delivery. Never leave a captured payment without a corresponding order; run reconciliation jobs that detect and fix orphaned payments.

Should checkout use distributed transactions (2PC)?

Avoid two-phase commit across payment, inventory, and order services — it doesn't scale and creates tight coupling. Use the saga pattern: each step is a local transaction with compensating actions on failure. Payment fails after inventory reserved? Release the reservation. Order creation fails after payment? Refund and release. Each step is idempotent so retries are safe.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →