Lesson 88 of 129
E-Commerce Microservices Design
This article designs an online store as a set of microservices: catalog, cart, order, inventory, payment, shipping, and notification. It applies the patterns from the rest of the microservices articles to one system: database per service, the saga pattern, the transactional outbox, an API gateway, resilience mechanisms, and distributed tracing.
Requirements
Functional
- Browse and search products.
- Add items to a cart and check out.
- Place an order, pay for it, and track its status.
- Reserve stock so the store never sells more units than it has.
- Ship orders and notify customers by email or push.
- Cancel an order before shipment and refund the payment.
Non-functional
- Browsing is read-heavy and must stay fast and available even when checkout dependencies fail.
- Checkout must never charge a customer without creating an order, and must never oversell stock.
- Order and payment data must be durable.
- Each team must be able to deploy its service independently.
- Payment card data must be isolated from the rest of the system.
Estimation
Assumptions: 10 million monthly active users, 1 million orders per day, 100 product page views per order.
- Orders:
1,000,000 / 86,400 sis about 12 orders per second on average. Assuming a peak of 10x, about 120 orders per second. - Product views:
100 x 1,000,000 = 100,000,000per day, about 1,150 per second on average, roughly 12,000 per second at 10x peak. Reads dominate writes by about 100 to 1, which justifies caching and a separate search index. - Order storage: 1 KB per order (header plus lines) gives
1,000,000 x 1 KB = 1 GBper day, about 365 GB per year. One replicated relational database handles this; sharding by customer ID is a later option. - Catalog: 10 million products at 5 KB of metadata is 50 GB. It fits on one node, so replication is for availability and read scaling rather than capacity.
The numbers show that write volume is modest and read volume is high. The main design difficulty is correctness across services, not raw throughput.
API design
Clients call the gateway with REST over HTTPS. Services call each other with gRPC for synchronous requests and publish events to a broker such as Kafka for asynchronous flows.
| Endpoint | Purpose |
|---|---|
GET /products?query=&page= |
Search and list products |
GET /products/{id} |
Product detail with price and availability |
PUT /cart/items/{sku} |
Set the quantity of an item in the cart |
POST /orders with header Idempotency-Key |
Create an order from the cart |
GET /orders/{id} |
Order status and history |
POST /orders/{id}/cancel |
Request cancellation |
The Idempotency-Key header lets a client retry POST /orders after a timeout without creating two orders. The order service stores the key with the created order under a unique constraint on the customer ID and key, and returns the stored result for a repeated key. The constraint also makes two concurrent requests with the same key resolve to one order.
Data model
Each service owns its data store, and no other service reads it directly.
| Service | Owns | Store | Reasoning |
|---|---|---|---|
| Catalog | Products, categories, prices | Document store plus a search index | Flexible attributes per product type, read-heavy |
| Cart | Active carts | Key-value store (for example Redis with persistence, or DynamoDB) | Access by customer ID only, short-lived, low latency |
| Order | Orders, order lines, saga state, outbox | Relational database | Transactions, constraints, auditability |
| Inventory | Stock levels, reservations | Relational database | Atomic conditional updates on counters |
| Payment | Payment attempts, refunds | Relational database, isolated network segment | Strong consistency, compliance scope |
| Shipping | Shipments, tracking events | Relational or document store | Lifecycle records with many status updates |
| Notification | Templates, delivery log | Document store | Append-heavy, no cross-entity transactions |
erDiagram
ORDER ||--|{ ORDER_LINE : contains
ORDER {
uuid id
uuid customer_id
string status
string idempotency_key
int total_cents
}
ORDER_LINE {
uuid order_id
string sku
int quantity
int unit_price_cents
}
RESERVATION {
uuid order_id
string sku
int quantity
string state
}
ORDER ||--o{ RESERVATION : "referenced by id only"The order service copies the product name and unit price into each order line at checkout. It does not join to the catalog later, so an order remains correct when the catalog price changes. The RESERVATION table belongs to inventory and references the order by ID only, with no foreign key across databases.
High-level design
flowchart TD
C[Web and mobile clients] --> GW[API gateway]
GW --> CAT[Catalog service]
GW --> CART[Cart service]
GW --> ORD[Order service]
CAT --> SI[(Search index)]
CAT --> CDB[(Catalog store)]
CART --> RD[(Cart store)]
ORD --> ODB[(Order DB + outbox)]
ORD -->|gRPC| INV[Inventory service]
ORD -->|gRPC| CAT
INV --> IDB[(Inventory DB)]
ORD -->|events| BUS{{Event broker}}
BUS --> INV
BUS --> PAY[Payment service]
BUS --> SHP[Shipping service]
BUS --> NOT[Notification service]
PAY --> PDB[(Payment DB)]
PAY --> PSP[External payment provider]
SHP --> SDB[(Shipping DB)]In the diagram, the gRPC edges are synchronous queries, such as a stock check or a price re-read at checkout. The event broker edges carry the saga commands and replies, which are messages published through the outbox. The sequence diagram below shows the saga logically and omits the broker.
Component responsibilities:
- API gateway. Terminates TLS, authenticates the caller (validating a token), applies per-client rate limits, and routes requests. It does not contain business logic.
- Catalog. Serves product data and search. Behind a CDN and a cache for anonymous traffic. It receives price and description updates from a merchandising back office.
- Cart. Stores one cart per customer. It holds no stock and makes no promises about availability.
- Order. The entry point for checkout. Creates the order and coordinates the order saga.
- Inventory. Owns stock counts and reservations.
- Payment. Talks to an external payment provider. It stores tokens and provider references, not raw card numbers, which keeps the rest of the system out of card-data compliance scope.
- Shipping. Creates shipments once payment is confirmed and reports tracking events.
- Notification. Consumes events and sends messages. It has no synchronous callers.
Deep dive
The order saga
An order touches inventory, payment, and shipping, each with its own database. A distributed ACID transaction across them would need two-phase commit, which blocks on coordinator failure and is not supported by external payment providers. The design uses a saga: a sequence of local transactions, each with a compensating action.
| Step | Service | Action | Compensation |
|---|---|---|---|
| 1 | Order | Create order in PENDING |
Mark CANCELLED |
| 2 | Inventory | Reserve stock | Release reservation |
| 3 | Payment | Charge the customer | Refund |
| 4 | Order | Mark CONFIRMED |
none (pivot completed) |
| 5 | Shipping | Create shipment | Cancel shipment while possible |
Reserving stock before charging means a failed payment only requires releasing a reservation, which is cheap and always possible. Charging first would require a refund, which is slower and visible to the customer.
The saga uses orchestration: the order service holds the saga state and sends commands, which keeps the flow readable in one place. Choreography, where each service reacts to the previous service's event, would spread the flow across five services.
sequenceDiagram
participant C as Client
participant O as Order service
participant I as Inventory
participant P as Payment
participant S as Shipping
C->>O: POST /orders (Idempotency-Key)
O->>O: Save order PENDING and outbox message
O->>I: ReserveStock
I-->>O: StockReserved
O->>P: ChargePayment
alt payment succeeds
P-->>O: PaymentSucceeded
O->>O: Mark CONFIRMED
O->>S: CreateShipment
else payment fails
P-->>O: PaymentFailed
O->>I: ReleaseStock
O->>O: Mark CANCELLED
endThe saga has no isolation, so intermediate states are visible. A customer can see a PENDING order, and stock is reserved but not yet sold. The design handles this with explicit states (PENDING, CONFIRMED, CANCELLED) and reservation expiry: an unconfirmed reservation is released after a timeout, so an abandoned saga cannot hold stock forever.
Reliable messaging
Each step must update the local database and send a message. Writing to the database and then to the broker separately can fail between the two, leaving the state changed with no message sent. Every service uses the transactional outbox: the message is inserted into an outbox table in the same local transaction as the state change, and a relay (a poller or a change-data-capture connector) publishes it to the broker.
The relay gives at-least-once delivery, so consumers may see duplicates. Each consumer stores the IDs of processed messages in the same transaction as its own update, following idempotent consumer rules. Payment additionally sends its own idempotency key (the order ID plus attempt number) to the external provider, so a retried charge request does not charge twice. The key stays the same for every network retry of one charge. A new attempt number is used only after the provider has confirmed that the previous attempt failed.
Inventory and overselling
The inventory service decrements stock with a conditional update in one statement:
UPDATE stock SET available = available - :qty
WHERE sku = :sku AND available >= :qtyIf zero rows are updated, the reservation fails. On success, the same transaction inserts a RESERVATION row with an expiry time. The database serializes concurrent updates on the same row, so two customers cannot both reserve the last unit. A popular item makes this a hot row. The mitigation is to split the counter for that SKU into several sub-counters and reserve from any one that has enough stock. A request for more units than any single sub-counter holds needs extra handling, such as reserving across several sub-counters in one transaction.
Queries across services
The order history page needs order data and product names. The order line already holds the product name copy. For a page that combines data from several services, such as an order with live shipping status, the gateway or a backend for frontend calls the order and shipping services in parallel and merges the responses. For a product listing with price and stock indicator, catalog consumes StockChanged events and keeps a denormalized availability flag, per eventual consistency and cross-service queries.
Bottlenecks and scaling
- Catalog reads. Cache product pages at a CDN and in a distributed cache. Use replicas of the catalog store and the search index. Invalidate on update events.
- Cart. Partition the key-value store by customer ID. Carts are independent, so it scales horizontally.
- Order database. Start with a leader and replicas. If it becomes the bottleneck, shard by customer ID so one customer's orders stay on one shard.
- Inventory hot rows. Use counter splitting for flash-sale items, and queue reservation requests per SKU if contention persists.
- Broker. Partition topics by order ID so events for one order stay ordered. Consumer groups scale each consumer service independently.
- External payment provider. It is the least controllable dependency. Wrap calls in a timeout and a circuit breaker, and use retries with backoff and jitter.
- Failure isolation. Browsing must not depend on checkout. Separate connection pools (bulkheads) per dependency, and fallbacks such as showing cached prices when inventory is unreachable. The order service sheds or queues checkout requests under overload rather than failing everything.
Cross-cutting concerns
Observability. Each request carries a W3C traceparent header from the gateway through gRPC calls and into message headers, so a trace spans the synchronous path and the asynchronous saga steps. Each service exposes RED metrics (rate, errors, duration) and structured logs that include the trace ID. Alerts fire on symptoms such as checkout success rate and order-to-confirmation latency. See observability.
Security. The gateway validates the user's token and passes identity to services. Services authenticate each other with mutual TLS via a service mesh or equivalent, and authorize on the caller's service identity. See zero trust.
Deployment. Each service ships in its own container image and is deployed on Kubernetes with canary releases and feature flags. Database changes use the expand-contract pattern so old and new versions of a service run side by side. See CI/CD and deployment strategies.
Trade-offs and follow-up questions
- Why not one modular monolith? At the estimated load, a modular monolith would work. Microservices are justified here by independent team deployment, isolating card data in the payment service, and scaling catalog reads separately from checkout. With a single small team, the monolith is the better start.
- Orchestration versus choreography. Orchestration adds a component with saga state that must be highly available. It makes the flow explicit and simplifies adding steps such as fraud checks.
- Reserve before or after payment? Reserving first can hold stock for customers whose payment then fails. Expiry bounds the cost. Charging first turns every stock failure into a refund.
- What if the payment provider times out? The outcome is unknown. The saga queries the provider using the idempotency key before deciding to retry or compensate, rather than assuming failure.
- How is a cancellation after shipment handled? The compensation "cancel shipment" is only possible before the carrier picks up the parcel. After that, cancellation becomes a return workflow with its own saga.
- How do prices stay consistent between cart and order? The order service re-reads the current price from catalog at checkout and shows the customer any change before confirming.
