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.

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.

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 informationproducts, 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.

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());
}
}
}
```
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:
- Create order as
SUBMITTED - Reserve inventory
- Charge payment
- Mark order as
PAID - Publish
OrderPaidevent - 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.

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:
OrderServiceInventoryServicePaymentServiceCheckoutOrchestrator
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.