AlgoDaily

Real-World Project: E-Commerce System Design

Read at your own pace. Code execution, quizzes, progress tracking, and the AI tutor are available in the interactive view with JavaScript enabled.

Project Orientation

From concepts to a real system

Now that we've covered High-Level System Design, Low-Level Design Principles, Microservices Architecture, Data Design and Database Management, Cloud-based Architecture, and Design Patterns and Best Practices, we're ready to combine them.

Our project: an e-commerce system like a small Amazon-style store.

By the end, you should be able to explain:

  • What services exist and why
  • Which data each service owns
  • How checkout flows across services
  • Where transactions are safe, and where we need compensation
  • How Java classes enforce business rules locally

Think of this lesson as the design review before a team starts building.

Project Orientation

Requirements First

What are we designing?

As we learned in Introduction to System Design and Architecture, requirements drive design. We will design for a realistic but scoped version of e-commerce.

Functional requirements

Users can:

  • Browse products
  • Add items to cart
  • Place an order
  • Pay for an order
  • Reserve inventory
  • Track order status

Admins can:

  • Add or update products
  • View orders
Non-functional requirements

The system should be:

  • Available: users can browse even if payment is temporarily down
  • Consistent where it matters: no paid order should disappear
  • Scalable: product browsing traffic is much higher than checkout traffic
  • Observable: failures in checkout must be traceable
Key design tradeoff

Browsing can be eventually updated, but checkout needs stronger correctness. That difference shapes the architecture.

High-Level Blueprint

Services and responsibilities

Using the HLD approach from High-Level System Design, we draw boundaries around business capabilities.

[React Web App]
      |
      v
[API Gateway]
      |
      +--> [Catalog Service] ----> Product DB
      |
      +--> [Cart Service] -------> Cart DB / Cache
      |
      +--> [Order Service] ------> Order DB
      |          |
      |          +--> [Inventory Service] --> Inventory DB
      |          |
      |          +--> [Payment Service] ----> Payment Provider
      |
      +--> [Notification Service] -> Email/SMS
Why these boundaries?
  • Catalog changes independently from checkout
  • Cart can tolerate temporary/session-like data patterns
  • Order owns the customer purchase record
  • Inventory owns stock counts and reservations
  • Payment isolates external provider complexity
  • Notification is asynchronous and should not block checkout

The API Gateway gives the React app one clean entry point while services remain independently deployable.

High-Level Blueprint

Service Boundary Check

A team suggests putting product browsing, cart storage, payment provider calls, and shipment tracking all inside one CheckoutService because checkout eventually touches them all.

What is the best response?

Data Ownership

Each service owns its truth

From Data Design and Database Management, we know data design starts with domain meaning, not tables.

In this project, avoid one giant shared database. Instead, each service owns the data it is responsible for.

Service Owns Example tables/documents Catalog Product information products, categories, product_images Cart Temporary shopping intent carts, cart_items Order Purchase record orders, order_items, order_events Inventory Stock and reservations stock_items, reservations Payment Payment attempts payments, refunds
Important rule

Other services may reference an ID, but they should not directly edit another service's database.

For example, Order Service can store productId, productNameSnapshot, and unitPriceSnapshot, but it should not update the Catalog database.

Data Ownership

Order Aggregate

Low-level design inside Order Service

At the class level, we use the LLD ideas from Low-Level Design Principles: encapsulate rules and prevent invalid states.

An Order should not be just a bag of public fields. It should protect rules like:

  • You cannot pay an empty order
  • You cannot ship an unpaid order
  • You cannot add items after submission
  • Cancellation rules depend on status

Here is a standalone Java example of an Order aggregate enforcing those rules.

```java
import java.util.*;

public class EcommerceOrderLLD {
    enum OrderStatus { DRAFT, SUBMITTED, PAID, SHIPPED, CANCELLED }

    static class OrderItem {
        final String productId;
        final String name;
        final int quantity;
        final int unitPriceCents;

        OrderItem(String productId, String name, int quantity, int unitPriceCents) {
            if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
            this.productId = productId;
            this.name = name;
            this.quantity = quantity;
            this.unitPriceCents = unitPriceCents;
        }

        int subtotal() {
            return quantity * unitPriceCents;
        }
    }

    static class Order {
        private final String id;
        private final List<OrderItem> items = new ArrayList<>();
        private OrderStatus status = OrderStatus.DRAFT;

        Order(String id) {
            this.id = id;
        }

        void addItem(OrderItem item) {
            requireStatus(OrderStatus.DRAFT, "Can only add items while order is DRAFT");
            items.add(item);
        }

        void submit() {
            requireStatus(OrderStatus.DRAFT, "Only DRAFT orders can be submitted");
            if (items.isEmpty()) throw new IllegalStateException("Cannot submit empty order");
            status = OrderStatus.SUBMITTED;
        }

        void markPaid() {
            requireStatus(OrderStatus.SUBMITTED, "Only SUBMITTED orders can be paid");
            status = OrderStatus.PAID;
        }

        void ship() {
            requireStatus(OrderStatus.PAID, "Only PAID orders can be shipped");
            status = OrderStatus.SHIPPED;
        }

        void cancel() {
            if (status == OrderStatus.SHIPPED) {
                throw new IllegalStateException("Cannot cancel an already shipped order");
            }
            status = OrderStatus.CANCELLED;
        }

        int totalCents() {
            return items.stream().mapToInt(OrderItem::subtotal).sum();
        }

        private void requireStatus(OrderStatus expected, String message) {
            if (status != expected) throw new IllegalStateException(message + ". Current: " + status);
        }

        @Override
        public String toString() {
            return "Order{id='" + id + "', status=" + status + ", totalCents=" + totalCents() + "}";
        }
    }

    public static void main(String[] args) {
        Order order = new Order("order-1001");
        order.addItem(new OrderItem("p-1", "Keyboard", 1, 4999));
        order.addItem(new OrderItem("p-2", "Mouse", 2, 1999));

        System.out.println(order);
        order.submit();
        order.markPaid();
        order.ship();
        System.out.println(order);

        try {
            order.cancel();
        } catch (Exception ex) {
            System.out.println("Expected rule violation: " + ex.getMessage());
        }
    }
}
```

Shared Database?

True or false:

In a microservices e-commerce system, it is usually a good idea for OrderService, InventoryService, and PaymentService to directly write to the same tables so checkout can be implemented as one large database transaction.

Checkout Workflow

The hardest flow: placing an order

Checkout crosses multiple services, so we must design the failure path, not just the happy path.

A common flow:

  1. Create order as SUBMITTED
  2. Reserve inventory
  3. Charge payment
  4. Mark order as PAID
  5. Publish OrderPaid event
  6. Notification service sends confirmation

But what if payment fails after inventory was reserved?

Then we need a compensating action: release the reservation and mark the order as failed/cancelled. This is the practical side of the failure thinking from Microservices Architecture and Cloud-based Architecture.

Checkout Workflow

Saga Simulation

Java checkout orchestration example

This is a small standalone simulation of a checkout saga.

It is not using Spring Boot yet, but the structure maps cleanly to Spring services:

  • OrderService
  • InventoryService
  • PaymentService
  • CheckoutOrchestrator

The key point: each service does its own local work, and the orchestrator compensates if a later step fails.

```java
import java.util.*;

public class CheckoutSagaDemo {
    static class Order {
        final String id;
        final int amountCents;
        String status = "SUBMITTED";

        Order(String id, int amountCents) {
            this.id = id;
            this.amountCents = amountCents;
        }
    }

    static class OrderService {
        Order createOrder(String id, int amountCents) {
            Order order = new Order(id, amountCents);
            System.out.println("Order created: " + order.id + " status=" + order.status);
            return order;
        }

        void markPaid(Order order) {
            order.status = "PAID";
            System.out.println("Order marked PAID: " + order.id);
        }

        void cancel(Order order, String reason) {
            order.status = "CANCELLED";
            System.out.println("Order cancelled: " + order.id + " reason=" + reason);
        }
    }

    static class InventoryService {
        private final Set<String> reservations = new HashSet<>();

        void reserve(String orderId) {
            reservations.add(orderId);
            System.out.println("Inventory reserved for " + orderId);
        }

        void release(String orderId) {
            reservations.remove(orderId);
            System.out.println("Inventory released for " + orderId);
        }
    }

    static class PaymentService {
        void charge(String orderId, int amountCents) {
            System.out.println("Charging payment for " + orderId + ": " + amountCents + " cents");
            if (amountCents > 10000) {
                throw new RuntimeException("Card declined for high-value test order");
            }
            System.out.println("Payment successful for " + orderId);
        }
    }

    static class CheckoutOrchestrator {
        private final OrderService orders = new OrderService();
        private final InventoryService inventory = new InventoryService();
        private final PaymentService payments = new PaymentService();

        void checkout(String orderId, int amountCents) {
            Order order = orders.createOrder(orderId, amountCents);
            boolean inventoryReserved = false;

            try {
                inventory.reserve(order.id);
                inventoryReserved = true;

                payments.charge(order.id, order.amountCents);

                orders.markPaid(order);
                System.out.println("Checkout completed for " + order.id + " finalStatus=" + order.status);
            } catch (Exception ex) {
                System.out.println("Checkout failed for " + order.id + ": " + ex.getMessage());
                if (inventoryReserved) inventory.release(order.id);
                orders.cancel(order, ex.getMessage());
                System.out.println("Checkout compensated for " + order.id + " finalStatus=" + order.status);
            }
        }
    }

    public static void main(String[] args) {
        CheckoutOrchestrator orchestrator = new CheckoutOrchestrator();

        System.out.println("--- Successful checkout ---");
        orchestrator.checkout("order-2001", 7500);

        System.out.println("\n--- Failed checkout with compensation ---");
        orchestrator.checkout("order-2002", 15000);
    }
}
```

API Shape

Practical API endpoints

The React frontend should not need to know every backend detail. It should call stable APIs through the gateway.

Example external-facing endpoints:

GET    /api/products?query=keyboard
GET    /api/products/{productId}
POST   /api/cart/items
GET    /api/cart
POST   /api/checkout
GET    /api/orders/{orderId}

Internally, checkout may call:

POST /orders
POST /inventory/reservations
POST /payments/charges
POST /orders/{orderId}/mark-paid
DELETE /inventory/reservations/{reservationId}
Design note

External APIs are product-facing and user-friendly. Internal APIs are capability-focused and service-to-service.

In the next lesson, we will focus on how this kind of codebase is organized, versioned, and integrated without teams stepping on each other.

Checkout Blank

Fill in the blank:

When payment fails after inventory has already been reserved, the checkout workflow should run a compensating action to ______ the inventory reservation.

Open interactive view →