How to Design an E-Commerce Checkout & Cart System
Building a Production-Grade Shopping Cart & Checkout Pipeline — Cart Management, Payment Processing, Inventory, and Order Fulfillment
1. Introduction & Why Checkout is the Hardest Part of E-Commerce
The e-commerce checkout and cart system is arguably the most complex and business-critical component of any online retail platform. It sits at the exact intersection where browsing turns into revenue — every millisecond of latency, every confusing UI element, every failed payment directly impacts the bottom line. According to Baymard Institute research, the average documented online shopping cart abandonment rate is approximately 70%. This means for every 10 users who add items to their cart, only 3 complete a purchase. While not all of this abandonment is due to technical issues (some users are just browsing or comparing prices), a significant portion is caused by slow checkout pages, unexpected shipping costs, lack of payment options, and system errors during the checkout process.
Building a checkout system is deceptively simple at the surface level: a user adds items, enters shipping information, pays, and receives an order confirmation. But beneath this simple surface lies an extraordinarily complex distributed system. Consider the sheer number of systems that must coordinate for a single checkout: the cart service managing item state, the inventory service reserving stock, the pricing engine calculating discounts and taxes, the shipping service fetching carrier rates, the payment service processing transactions, the order service creating the order record, the notification service sending confirmations, and the analytics service tracking conversion events. Each of these systems must be consistent, performant, and fault-tolerant. A failure in any single system — a payment gateway timeout, an inventory service outage, a tax calculation error — can lose the sale entirely.
The challenge intensifies at scale. Amazon processes approximately 66,000 orders per hour (roughly 18 orders per second). During peak events like Prime Day or Black Friday, this can spike to 10x or more. At these volumes, the cart and checkout system must handle millions of concurrent sessions, process thousands of payments per second, maintain real-time inventory accuracy across multiple warehouses, and still deliver sub-second response times. The system must also handle edge cases that are unique to e-commerce: flash sales that deplete inventory in seconds, coupon codes that must be validated atomically, cart items that become unavailable mid-checkout, price changes that occur while items are in the cart, and split shipments where items ship from different warehouses.
Real-World Case Studies
Understanding how major e-commerce platforms approach the cart and checkout problem provides practical insights for our design. Each platform has unique constraints and innovations shaped by their specific business requirements and scale:
| Company | Scale | Key Innovation | Technical Challenge |
|---|---|---|---|
| Amazon | 66K orders/hour | 1-Click ordering, anticipatory shipping | Sub-100ms checkout for Prime members, real-time inventory across 200+ fulfillment centers |
| Shopify | 4.4M merchants | Multi-tenant checkout, Shop Pay | Tenant isolation, per-store pricing rules, 1M+ concurrent checkouts |
| Stripe | Billions of transactions/year | Idempotency keys, Radar ML fraud detection | Exactly-once payment processing, multi-currency, 135+ currencies |
| Alibaba | 122K orders/second (peak Singles Day) | Inventory pre-warming, elastic scaling | Handling 100x normal traffic in minutes, distributed inventory locks |
| Walmart | Billions in annual e-commerce revenue | In-store + online cart unification | Real-time cross-channel inventory, curbside pickup scheduling |
Amazon's 1-Click ordering is a masterclass in checkout optimization. By pre-storing payment and shipping information and using a cryptographic token to authorize purchases, Amazon reduced checkout to a single button press. This seemingly simple feature requires an enormous backend infrastructure: the payment token vault must be PCI-DSS compliant, the inventory check must complete within 50ms, and the fraud detection must run synchronously before the order is confirmed. The result is a checkout experience so frictionless that it reportedly accounts for a significant portion of Amazon's impulse purchases.
Shopify's multi-tenant checkout presents a different but equally challenging problem. Each of Shopify's 4.4 million merchants has their own checkout configuration: custom discount rules, tax settings, shipping zones, payment gateways, and branding. The checkout system must efficiently serve millions of different stores while ensuring that one merchant's configuration changes never affect another's. This requires careful namespace isolation, per-tenant configuration caching, and a checkout rendering engine that can dynamically apply merchant-specific customizations without sacrificing performance.
The core lesson from these case studies is that checkout optimization is not about any single technique — it is about systematically eliminating friction at every step of the funnel while maintaining reliability and security. The system we design in this guide draws from all of these approaches, combining the best practices of industry leaders into a cohesive, production-ready architecture.
2. Functional & Non-Functional Requirements
Functional Requirements
- Cart Management: Users can add items (with quantity and variant selection), remove items, update quantities, and view their cart. The cart must support product variants (size, color), gift wrapping options, and custom attributes.
- Session Continuity: Anonymous users maintain cart state across sessions using device fingerprinting and cookies. Authenticated users have their cart persisted to their account and synced across devices.
- Pricing & Discounts: Real-time price calculation including base price, volume discounts, coupon/promo code application, tax computation, and shipping cost estimation.
- Inventory Reservation: When a user enters checkout, inventory is soft-locked with a TTL (typically 10 minutes). If the checkout is abandoned, the lock expires and inventory returns to the available pool.
- Checkout Flow: Multi-step checkout supporting address entry with validation, shipping method selection, payment method entry, order review, and order placement.
- Payment Processing: Integration with Stripe (and other payment gateways) supporting credit/debit cards, digital wallets (Apple Pay, Google Pay), and buy-now-pay-later (BNPL) options. Must handle 3D Secure authentication.
- Order Creation: On successful payment, create the order with a deterministic order ID, capture line items, apply all pricing, and trigger fulfillment workflows.
- Cart Abandonment Recovery: Track cart abandonment events and trigger recovery emails with deep links back to the user's cart.
- A/B Testing: Support experiment variants for checkout flow modifications (single-page vs multi-step, different payment UIs, etc.).
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Cart API latency (p99) | < 100ms | Cart operations are on the critical browsing path |
| Checkout API latency (p99) | < 500ms | Checkout includes external service calls (payment, tax, shipping) |
| Availability | 99.99% (52 min/year downtime) | Every minute of downtime directly loses revenue |
| Concurrent users | 10M+ simultaneous carts | Must handle peak traffic events |
| Orders per second | 10,000+ at peak | Flash sales and holiday peaks |
| Data durability | 99.999999999% (11 nines) | Cart and order data must never be lost |
| Cart TTL | 30 days for anonymous, indefinite for authenticated | Balance storage cost with user experience |
| Inventory reservation TTL | 10 minutes | Prevent hoarding while allowing time for checkout |
3. Capacity Estimation & Scale
Traffic Estimation
Let us estimate the system capacity for a mid-to-large e-commerce platform handling substantial traffic volumes:
| Metric | Daily | Per Second (avg) | Per Second (peak) |
|---|---|---|---|
| Page views | 500M | ~5,800 | ~17,400 (3x) |
| Cart operations (add/update/remove) | 100M | ~1,157 | ~3,470 |
| Checkout initiations | 30M | ~347 | ~1,041 |
| Orders completed | 15M | ~174 | ~522 |
| Payment transactions | 16M (includes retries) | ~185 | ~555 |
Storage Estimation
| Data Type | Size per Record | Daily Volume | Daily Storage | Monthly |
|---|---|---|---|---|
| Cart documents (Redis) | ~2KB | 10M active carts | 20 GB | 600 GB |
| Cart snapshots (DB) | ~1KB | 30M (one per checkout) | 30 GB | 900 GB |
| Orders | ~4KB | 15M | 60 GB | 1.8 TB |
| Payment records | ~2KB | 16M | 32 GB | 960 GB |
| Inventory reservations | ~200B | 30M (short-lived) | 6 GB | ~6 GB (TTL-managed) |
Bandwidth Estimation
The cart API serving 3,470 peak requests per second with average response sizes of 5KB requires approximately 17 MB/s of outbound bandwidth for cart responses alone. The checkout API at 1,041 peak requests per second with 20KB average response sizes (including shipping options and pricing breakdowns) requires approximately 21 MB/s. Total system bandwidth including internal service-to-service communication is estimated at 200-500 MB/s during peak. This is well within the capacity of modern cloud networking infrastructure and does not represent a bottleneck.
4. High-Level Architecture Overview
The e-commerce checkout and cart system follows a microservices architecture with clear domain boundaries. Each service owns its data and communicates with other services through well-defined APIs and asynchronous events. This architecture allows independent scaling (the cart service can scale separately from the payment service), independent deployment, and fault isolation (a failure in the recommendation service should never prevent checkout).
Web/Mobile] --> CDN[CDN / Edge] CDN --> Gateway[API Gateway
Rate Limiting + Auth] Gateway --> CartService[Cart Service] Gateway --> CheckoutService[Checkout Service] Gateway --> OrderService[Order Service] CartService --> Redis[(Redis Cluster
Cart Cache)] CartService --> CartDB[(PostgreSQL
Cart Persistence)] CheckoutService --> InventorySvc[Inventory Service] CheckoutService --> PricingSvc[Pricing Engine] CheckoutService --> ShippingSvc[Shipping Service] CheckoutService --> PaymentSvc[Payment Service] CheckoutService --> TaxSvc[Tax Calculation Service] PaymentSvc --> Stripe[Stripe API] PaymentSvc --> PaymentDB[(PostgreSQL
Payment Ledger)] OrderService --> OrderDB[(PostgreSQL
Orders)] OrderService --> EventBus[Event Bus
Kafka] EventBus --> NotificationSvc[Notification Service] EventBus --> FulfillmentSvc[Fulfillment Service] EventBus --> AnalyticsSvc[Analytics Service] EventBus --> AbandonmentSvc[Cart Abandonment Tracker] InventorySvc --> InventoryDB[(PostgreSQL
Inventory)] InventorySvc --> InventoryCache[(Redis
Inventory Cache)]
Service Responsibilities
| Service | Responsibility | Storage | Scale Factor |
|---|---|---|---|
| Cart Service | CRUD operations on shopping carts, session management | Redis (hot) + PostgreSQL (cold) | Cart count |
| Checkout Service | Orchestrates the checkout flow across all services | Stateless (uses Redis for checkout state) | Checkout initiations |
| Inventory Service | Stock levels, reservation management, warehouse allocation | PostgreSQL + Redis cache | SKU count x warehouse count |
| Pricing Engine | Price calculation, discounts, coupons, promotions | Redis (rules cache) + PostgreSQL | Pricing rule complexity |
| Tax Service | Tax calculation for jurisdictions, tax-exempt handling | Tax rules cache + external API (Avalara/TaxJar) | Transaction count |
| Shipping Service | Carrier rate shopping, delivery estimation | Rate cache + external carrier APIs | Checkout initiations |
| Payment Service | Payment authorization, capture, refunds, vaulting | PostgreSQL (encrypted) | Payment count |
| Order Service | Order lifecycle management, status tracking | PostgreSQL + event store | Order count |
| Notification Service | Email, SMS, push notifications for order events | Message queue + template store | Order events |
| Abandonment Service | Cart abandonment detection and recovery email triggers | ClickHouse (analytics) + PostgreSQL | Abandoned cart count |
Communication Patterns
The architecture uses two primary communication patterns. Synchronous communication (gRPC or REST) is used for the checkout flow where the checkout orchestrator needs immediate responses from each service (inventory check, price calculation, shipping rates, tax calculation). These calls are bounded by strict timeouts and have circuit breaker protection. Asynchronous communication (Kafka events) is used for side effects that do not need to block the checkout response: sending confirmation emails, updating analytics, triggering fulfillment, and tracking cart abandonment. This hybrid approach ensures the checkout response is returned to the user as quickly as possible while all downstream processing happens reliably in the background.
5. Data Model & Storage Schema
The data model must support fast cart reads and writes, efficient inventory reservations, and durable order records. We use a polyglot persistence approach: Redis for hot cart data, PostgreSQL for durable records, and Kafka for event sourcing.
Cart Schema (PostgreSQL)
SQL
CREATE TABLE carts (
cart_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(user_id),
session_id VARCHAR(128),
status VARCHAR(20) NOT NULL DEFAULT 'active',
currency CHAR(3) NOT NULL DEFAULT 'USD',
subtotal DECIMAL(12,2) DEFAULT 0,
discount_total DECIMAL(12,2) DEFAULT 0,
tax_total DECIMAL(12,2) DEFAULT 0,
shipping_total DECIMAL(12,2) DEFAULT 0,
grand_total DECIMAL(12,2) DEFAULT 0,
coupon_code VARCHAR(50),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
CONSTRAINT chk_status CHECK (status IN ('active','merged','converted','abandoned','expired'))
);
CREATE TABLE cart_items (
item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cart_id UUID NOT NULL REFERENCES carts(cart_id) ON DELETE CASCADE,
product_id UUID NOT NULL,
variant_id UUID,
sku VARCHAR(100) NOT NULL,
name VARCHAR(500) NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(12,2) NOT NULL,
line_total DECIMAL(12,2) NOT NULL,
weight_grams INT,
image_url VARCHAR(1000),
metadata JSONB DEFAULT '{}',
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(cart_id, product_id, variant_id)
);
CREATE INDEX idx_carts_user ON carts(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_carts_session ON carts(session_id);
CREATE INDEX idx_carts_expires ON carts(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX idx_cart_items_cart ON cart_items(cart_id);
Order Schema (PostgreSQL)
SQL
CREATE TABLE orders (
order_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_number VARCHAR(20) UNIQUE NOT NULL,
user_id UUID REFERENCES users(user_id),
status VARCHAR(30) NOT NULL DEFAULT 'pending',
currency CHAR(3) NOT NULL,
subtotal DECIMAL(12,2) NOT NULL,
discount_total DECIMAL(12,2) DEFAULT 0,
tax_total DECIMAL(12,2) NOT NULL,
shipping_total DECIMAL(12,2) NOT NULL,
grand_total DECIMAL(12,2) NOT NULL,
shipping_address JSONB NOT NULL,
billing_address JSONB NOT NULL,
shipping_method VARCHAR(50) NOT NULL,
payment_method VARCHAR(50) NOT NULL,
payment_intent_id VARCHAR(100),
idempotency_key VARCHAR(100) UNIQUE,
notes TEXT,
placed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
paid_at TIMESTAMPTZ,
shipped_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}'
);
CREATE TABLE order_items (
order_item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(order_id),
product_id UUID NOT NULL,
variant_id UUID,
sku VARCHAR(100) NOT NULL,
name VARCHAR(500) NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(12,2) NOT NULL,
discount_amount DECIMAL(12,2) DEFAULT 0,
tax_amount DECIMAL(12,2) NOT NULL,
line_total DECIMAL(12,2) NOT NULL
);
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_placed ON orders(placed_at DESC);
CREATE INDEX idx_orders_idempotency ON orders(idempotency_key);
Inventory Reservation Schema
SQL
CREATE TABLE inventory_reservations (
reservation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID REFERENCES orders(order_id),
sku VARCHAR(100) NOT NULL,
warehouse_id UUID NOT NULL,
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'reserved',
reserved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
released_at TIMESTAMPTZ,
UNIQUE(sku, warehouse_id, order_id)
);
CREATE INDEX idx_reservations_expiry ON inventory_reservations(expires_at)
WHERE status = 'reserved';
CREATE INDEX idx_reservations_sku ON inventory_reservations(sku)
WHERE status = 'reserved';
6. API Design
The API layer follows REST conventions with consistent resource naming, proper HTTP semantics, and comprehensive error responses. All endpoints require authentication (except cart operations for anonymous users which use session tokens). The API is versioned (v1) and follows a resource-oriented design pattern.
Cart APIs
| Method | Endpoint | Description | Auth |
|---|---|---|---|
GET | /api/v1/carts/{cartId} | Get cart with items and totals | Session or User |
POST | /api/v1/carts/{cartId}/items | Add item to cart | Session or User |
PATCH | /api/v1/carts/{cartId}/items/{itemId} | Update item quantity | Session or User |
DELETE | /api/v1/carts/{cartId}/items/{itemId} | Remove item from cart | Session or User |
POST | /api/v1/carts/{cartId}/coupon | Apply coupon code | Session or User |
DELETE | /api/v1/carts/{cartId}/coupon | Remove coupon code | Session or User |
POST | /api/v1/carts/merge | Merge anonymous cart into user cart | User |
Checkout APIs
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/checkout/initialize | Start checkout, reserve inventory, get pricing |
PUT | /api/v1/checkout/{checkoutId}/address | Set shipping address, get shipping rates |
PUT | /api/v1/checkout/{checkoutId}/shipping | Select shipping method |
POST | /api/v1/checkout/{checkoutId}/payment | Process payment and place order |
GET | /api/v1/checkout/{checkoutId}/summary | Get full checkout summary |
Order APIs
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/orders/{orderId} | Get order details |
GET | /api/v1/orders | List user's orders (paginated) |
POST | /api/v1/orders/{orderId}/cancel | Cancel an order |
POST | /api/v1/orders/{orderId}/refund | Request a refund |
Cart Add Item — Request and Response
JSON
// POST /api/v1/carts/{cartId}/items
{
"product_id": "prod_8xk2m9",
"variant_id": "var_blue_large",
"sku": "TSHIRT-BLU-L-001",
"quantity": 2,
"metadata": {
"gift_wrap": true,
"gift_message": "Happy Birthday!"
}
}
// Response 201 Created
{
"cart_id": "cart_3f8a2b",
"item": {
"item_id": "item_9z7c1d",
"product_id": "prod_8xk2m9",
"variant_id": "var_blue_large",
"sku": "TSHIRT-BLU-L-001",
"name": "Premium Cotton T-Shirt — Blue, Large",
"quantity": 2,
"unit_price": 29.99,
"line_total": 59.98,
"image_url": "https://cdn.example.com/products/tshirt-blue.jpg"
},
"totals": {
"item_count": 3,
"subtotal": 89.97,
"estimated_tax": 7.20,
"estimated_total": 97.17
}
}
7. Cart Service Design
The Cart Service is the highest-traffic service in the checkout ecosystem. It must handle millions of concurrent carts with sub-100ms latency for all CRUD operations. The service is designed as a stateless compute layer with a two-tier storage backend: Redis for hot active cart data and PostgreSQL for durable persistence.
Cart Operations Flow
Cart Service Implementation
C#
public class CartService : ICartService
{
private readonly IRedisCache _cache;
private readonly ICartRepository _repository;
private readonly IEventPublisher _events;
private readonly IProductClient _products;
private readonly ITotalsCalculator _totals;
public async Task<CartResponse> AddItemAsync(
Guid cartId, AddItemRequest request)
{
// 1. Load cart (Redis-first strategy)
var cart = await LoadCartAsync(cartId);
if (cart == null)
{
cart = await _repository.CreateCartAsync(
new Cart { CartId = cartId });
}
// 2. Validate product and get current pricing
var product = await _products
.GetProductAsync(request.ProductId);
if (product == null)
throw new NotFoundException(
$"Product {request.ProductId} not found");
if (!product.IsAvailable)
throw new ConflictException(
"Product is no longer available");
// 3. Check if item already exists in cart
var existing = cart.Items.FirstOrDefault(i =>
i.ProductId == request.ProductId &&
i.VariantId == request.VariantId);
if (existing != null)
{
existing.Quantity += request.Quantity;
existing.LineTotal =
existing.Quantity * existing.UnitPrice;
}
else
{
cart.Items.Add(new CartItem
{
ItemId = Guid.NewGuid(),
CartId = cartId,
ProductId = request.ProductId,
VariantId = request.VariantId,
Sku = product.Sku,
Name = product.Name,
Quantity = request.Quantity,
UnitPrice = product.Price,
LineTotal = request.Quantity * product.Price,
WeightGrams = product.WeightGrams,
ImageUrl = product.ImageUrl,
Metadata = request.Metadata ?? new(),
AddedAt = DateTime.UtcNow
});
}
// 4. Recalculate all totals
cart.Totals = await _totals
.CalculateCartTotalsAsync(cart);
// 5. Persist and cache
await _cache.SetCartAsync(cart,
TimeSpan.FromMinutes(30));
_ = _repository.UpsertCartAsync(cart); // fire-and-forget
// 6. Publish event
await _events.PublishAsync(new CartItemAdded
{
CartId = cartId,
ProductId = request.ProductId,
Quantity = request.Quantity,
Timestamp = DateTime.UtcNow
});
return MapToResponse(cart);
}
private async Task<Cart?> LoadCartAsync(Guid cartId)
{
var cached = await _cache.GetCartAsync(cartId);
if (cached != null) return cached;
var cart = await _repository
.GetCartWithItemsAsync(cartId);
if (cart != null)
{
await _cache.SetCartAsync(cart,
TimeSpan.FromMinutes(30));
}
return cart;
}
}
Cart Totals Calculation
The totals calculator is a pure function that takes the cart state and produces a complete pricing breakdown. It handles the cascading nature of e-commerce pricing: line totals feed into the subtotal, discounts reduce the subtotal, taxes are calculated on the discounted amount, and shipping is added last. This calculation must be deterministic — the same cart state must always produce the same totals, regardless of when or where it is calculated.
C#
public class TotalsCalculator : ITotalsCalculator
{
private readonly IPricingEngine _pricing;
private readonly ITaxService _tax;
private readonly IShippingService _shipping;
public async Task<CartTotals> CalculateCartTotalsAsync(
Cart cart)
{
// 1. Calculate line totals (quantity x unit_price)
foreach (var item in cart.Items)
{
item.LineTotal = item.Quantity * item.UnitPrice;
}
// 2. Subtotal = sum of all line totals
var subtotal = cart.Items.Sum(i => i.LineTotal);
// 3. Apply discounts and promotions
var discountResult = await _pricing
.CalculateDiscountsAsync(cart);
var discountTotal = discountResult.TotalDiscount;
var discountedSubtotal = subtotal - discountTotal;
// 4. Calculate tax on discounted amount
var taxResult = await _tax.CalculateTaxAsync(
discountedSubtotal, cart.ShippingAddress,
cart.Items);
var taxTotal = taxResult.TotalTax;
// 5. Shipping is calculated separately
var shippingTotal = cart.ShippingMethod != null
? await _shipping.GetRateAsync(
cart.ShippingMethod, cart)
: 0m;
// 6. Grand total
var grandTotal =
discountedSubtotal + taxTotal + shippingTotal;
return new CartTotals
{
Subtotal = subtotal,
DiscountTotal = discountTotal,
DiscountDetails = discountResult.AppliedDiscounts,
TaxTotal = taxTotal,
TaxDetails = taxResult.TaxBreakdown,
ShippingTotal = shippingTotal,
GrandTotal = grandTotal,
Currency = cart.Currency
};
}
}
8. Session Management & Anonymous vs Authenticated Cart
One of the most nuanced challenges in e-commerce cart design is managing the transition between anonymous and authenticated states. A user browsing without logging in has an anonymous cart identified by a session cookie. Once they log in, that anonymous cart must be seamlessly merged with any existing authenticated cart. This transition must be lossless (no items disappear), conflict-free (duplicates are handled gracefully), and fast (the user should not notice the merge happening).
Session Architecture
Cart Merge Strategy
C#
public class CartMergeService : ICartMergeService
{
public async Task<Cart> MergeCartsAsync(
Guid anonymousCartId, Guid userId)
{
var userCart = await _cartRepo
.GetActiveCartForUserAsync(userId);
var anonCart = await _cartRepo
.GetCartWithItemsAsync(anonymousCartId);
if (anonCart == null || !anonCart.Items.Any())
return userCart ?? await CreateCartForUser(userId);
if (userCart == null)
{
// No existing user cart — simply adopt the anon cart
anonCart.UserId = userId;
anonCart.SessionId = null;
await _cartRepo.UpdateCartAsync(anonCart);
return anonCart;
}
// Both carts have items — merge them
foreach (var anonItem in anonCart.Items)
{
var existingItem = userCart.Items.FirstOrDefault(i =>
i.ProductId == anonItem.ProductId &&
i.VariantId == anonItem.VariantId);
if (existingItem != null)
{
// Sum quantities, respecting max per customer
var maxQty = await GetMaxQuantityAsync(
anonItem.ProductId);
existingItem.Quantity = Math.Min(
existingItem.Quantity + anonItem.Quantity,
maxQty);
existingItem.LineTotal =
existingItem.Quantity * existingItem.UnitPrice;
}
else
{
// Item only in anonymous cart — add to user cart
anonItem.CartId = userCart.CartId;
anonItem.ItemId = Guid.NewGuid();
userCart.Items.Add(anonItem);
}
}
// Preserve the most recent coupon from either cart
if (!string.IsNullOrEmpty(anonCart.CouponCode) &&
string.IsNullOrEmpty(userCart.CouponCode))
{
userCart.CouponCode = anonCart.CouponCode;
}
// Recalculate totals and persist
userCart.Totals = await _totals
.CalculateCartTotalsAsync(userCart);
await _cartRepo.UpdateCartAsync(userCart);
// Mark anonymous cart as merged
anonCart.Status = CartStatus.Merged;
await _cartRepo.UpdateCartAsync(anonCart);
// Update cache
await _cache.SetCartAsync(userCart,
TimeSpan.FromMinutes(30));
await _cache.RemoveCartAsync(anonymousCartId);
return userCart;
}
}
Session Token Design
The session token for anonymous users is a cryptographically signed JWT containing the cart_id, a creation timestamp, and an HMAC signature. The token is stored in an HttpOnly, Secure, SameSite=Lax cookie with a 30-day expiry. The signature prevents tampering — a user cannot fabricate a cart_id to access another user's cart. The session token maps to a cart_id via a server-side lookup (Redis hash), providing a layer of indirection that allows cart reassignment during merge.
9. Cart Persistence — Redis + Database Strategy
The cart persistence layer uses a write-through caching strategy with Redis as the primary read store and PostgreSQL as the durable write store. This approach ensures that cart reads (which happen far more frequently than writes) are served from Redis with sub-millisecond latency, while PostgreSQL guarantees durability in case of Redis failure.
Cache Strategy
| Operation | Redis | PostgreSQL | Consistency |
|---|---|---|---|
| Read cart | Primary (TTL 30 min) | Fallback on cache miss | Eventual (acceptable for cart) |
| Add/remove item | Update + extend TTL | Async upsert (fire-and-forget) | Eventual (Redis is source of truth) |
| Checkout initialize | Read from cache | Sync read for durability | Strong (transactional) |
| Cart abandoned/expired | Delete from cache | Update status to expired | Eventual |
Redis Data Structure
Carts are stored in Redis as serialized JSON blobs under the key pattern cart:{cartId}. This is simpler than using Redis Hash structures and allows atomic read-modify-write operations with a distributed lock. The lock uses the Redis SET NX EX pattern with a 5-second TTL to prevent race conditions when multiple requests modify the same cart simultaneously.
C#
public class RedisCartCache : IRedisCache
{
private readonly IConnectionMultiplexer _redis;
private const string CartKeyPrefix = "cart:";
private const string LockKeyPrefix = "lock:cart:";
private static readonly TimeSpan CartTTL =
TimeSpan.FromMinutes(30);
private static readonly TimeSpan LockTTL =
TimeSpan.FromSeconds(5);
public async Task<Cart?> GetCartAsync(Guid cartId)
{
var db = _redis.GetDatabase();
var key = $"{CartKeyPrefix}{cartId}";
var json = await db.StringGetAsync(key);
if (json.IsNullOrEmpty) return null;
return JsonSerializer.Deserialize<Cart>(json!);
}
public async Task SetCartAsync(Cart cart,
TimeSpan? ttl = null)
{
var db = _redis.GetDatabase();
var key = $"{CartKeyPrefix}{cart.CartId}";
var json = JsonSerializer.Serialize(cart);
await db.StringSetAsync(key, json, ttl ?? CartTTL);
}
public async Task<bool> AcquireCartLockAsync(
Guid cartId)
{
var db = _redis.GetDatabase();
var lockKey = $"{LockKeyPrefix}{cartId}";
return await db.StringSetAsync(
lockKey, "1", LockTTL,
When.NotExists);
}
public async Task ReleaseCartLockAsync(Guid cartId)
{
var db = _redis.GetDatabase();
var lockKey = $"{LockKeyPrefix}{cartId}";
await db.KeyDeleteAsync(lockKey);
}
}
Redis Cluster Sharding
At scale (10M+ active carts), a single Redis instance cannot hold all cart data. We use Redis Cluster with hash slot-based sharding. The cart_id is used as the hash tag key, ensuring all operations for a given cart hit the same shard. With 16,384 hash slots distributed across 6 Redis masters (with replicas), each master handles approximately 1.67M slots. The average cart (2KB) means each master stores approximately 3.3GB of cart data — well within Redis in-memory capacity.
10. Inventory Reservation & Soft Lock with TTL
Inventory reservation is one of the most critical components of the checkout system. When a user begins checkout, the system must ensure the items in their cart are actually available and prevent other users from purchasing the same items simultaneously. This is achieved through a "soft lock" mechanism: inventory is temporarily reserved with a time-to-live (TTL), and if the checkout is not completed within the TTL window, the reservation expires and the inventory becomes available again.
Reservation Lifecycle
Inventory Service Implementation
C#
public class InventoryService : IInventoryService
{
private readonly IInventoryRepository _repo;
private readonly IDistributedLock _locks;
private readonly IRedisCache _cache;
private readonly TimeSpan ReservationTTL =
TimeSpan.FromMinutes(10);
public async Task<ReservationResult> ReserveInventoryAsync(
Guid orderId, List<ReservationRequest> items)
{
var results = new List<ItemReservationResult>();
// Sort items by SKU for deterministic lock ordering
// (prevents deadlocks)
items = items.OrderBy(i => i.Sku).ToList();
foreach (var item in items)
{
// Acquire per-SKU distributed lock
var lockKey = $"inventory:{item.Sku}";
var acquired = await _locks.AcquireAsync(
lockKey, TimeSpan.FromSeconds(5));
if (!acquired)
{
results.Add(new ItemReservationResult
{
Sku = item.Sku,
Success = false,
Reason = "System busy, please retry"
});
continue;
}
try
{
// Check available quantity
var available = await GetAvailableQuantityAsync(
item.Sku, item.WarehouseId);
if (available < item.Quantity)
{
results.Add(new ItemReservationResult
{
Sku = item.Sku,
Success = false,
Reason = available == 0
? "Out of stock"
: $"Only {available} available"
});
continue;
}
// Create reservation with TTL
var reservation = new InventoryReservation
{
ReservationId = Guid.NewGuid(),
OrderId = orderId,
Sku = item.Sku,
WarehouseId = item.WarehouseId,
Quantity = item.Quantity,
Status = "reserved",
ReservedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.Add(ReservationTTL)
};
await _repo.CreateReservationAsync(reservation);
// Update available quantity in cache
await _cache.IncrementInventoryAsync(
item.Sku, -item.Quantity);
results.Add(new ItemReservationResult
{
Sku = item.Sku,
Success = true,
ReservationId = reservation.ReservationId,
ExpiresAt = reservation.ExpiresAt
});
}
finally
{
await _locks.ReleaseAsync(lockKey);
}
}
var allSucceeded = results.All(r => r.Success);
return new ReservationResult
{
OrderId = orderId,
AllReserved = allSucceeded,
Items = results,
ExpiresAt = DateTime.UtcNow.Add(ReservationTTL)
};
}
public async Task<int> GetAvailableQuantityAsync(
string sku, Guid warehouseId)
{
// Try cache first
var cached = await _cache
.GetInventoryCountAsync(sku, warehouseId);
if (cached.HasValue) return cached.Value;
var reserved = await _repo
.GetActiveReservationCountAsync(sku, warehouseId);
var totalStock = await _repo
.GetTotalStockAsync(sku, warehouseId);
var available = totalStock - reserved;
await _cache.SetInventoryCountAsync(
sku, warehouseId, available,
TimeSpan.FromSeconds(30));
return available;
}
}
Reservation Expiration Worker
A background worker periodically scans for expired reservations and releases them back into the available inventory pool. This worker runs every 30 seconds and uses a batch query to find all reservations where expires_at < NOW() AND status = 'reserved'. Expired reservations are updated to status = 'released', and the corresponding inventory counts are decremented in the cache.
C#
public class ReservationExpirationWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ReservationExpirationWorker> _log;
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider
.GetRequiredService<IInventoryRepository>();
var cache = scope.ServiceProvider
.GetRequiredService<IRedisCache>();
var expired = await repo
.GetExpiredReservationsAsync(
batchSize: 500);
foreach (var reservation in expired)
{
reservation.Status = "released";
reservation.ReleasedAt = DateTime.UtcNow;
await repo.UpdateReservationAsync(
reservation);
// Return inventory to available pool
await cache.IncrementInventoryAsync(
reservation.Sku,
reservation.Quantity);
_log.LogInformation(
"Released expired reservation {Id} " +
"for {Sku} qty {Qty}",
reservation.ReservationId,
reservation.Sku,
reservation.Quantity);
}
if (expired.Any())
{
_log.LogInformation(
"Released {Count} expired reservations",
expired.Count);
}
}
catch (Exception ex)
{
_log.LogError(ex,
"Error processing expired reservations");
}
await Task.Delay(
TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
11. Pricing Engine — Discounts, Coupons & Tax Calculation
The pricing engine is responsible for the complete price calculation pipeline: base prices, volume discounts, promotional pricing, coupon/promo codes, gift cards, and tax computation. This pipeline must be deterministic (same inputs always produce same outputs), auditable (every price change must have a reason), and performant (pricing calculation should add less than 50ms to checkout latency).
Pricing Pipeline
Price Calculation Implementation
C#
public class PricingEngine : IPricingEngine
{
private readonly IDiscountRepository _discounts;
private readonly ICouponRepository _coupons;
private readonly IRuleEngine _rules;
public async Task<PricingResult> CalculatePriceAsync(
Cart cart)
{
var result = new PricingResult();
// Step 1: Base prices (already set on cart items)
result.BaseTotal = cart.Items
.Sum(i => i.Quantity * i.UnitPrice);
// Step 2: Product-level discounts
foreach (var item in cart.Items)
{
var productDiscounts = await _discounts
.GetActiveDiscountsForProductAsync(
item.ProductId);
foreach (var discount in productDiscounts)
{
if (await _rules.EvaluateAsync(
discount.Rules, cart))
{
var discountAmount = discount.Type
== DiscountType.Percentage
? item.LineTotal * discount.Value / 100
: Math.Min(discount.Value,
item.LineTotal);
result.AppliedDiscounts.Add(
new AppliedDiscount
{
Code = discount.Code,
Description = discount.Description,
Amount = discountAmount,
AppliesTo = DiscountScope.Product,
ProductId = item.ProductId
});
}
}
}
// Step 3: Cart-level promotions
var cartPromos = await _discounts
.GetActiveCartPromotionsAsync();
foreach (var promo in cartPromos)
{
if (await _rules.EvaluateAsync(
promo.Rules, cart))
{
var discountAmount = CalculatePromoDiscount(
promo, cart);
result.AppliedDiscounts.Add(
new AppliedDiscount
{
Code = promo.Code,
Description = promo.Description,
Amount = discountAmount,
AppliesTo = DiscountScope.Cart
});
}
}
// Step 4: Coupon code
if (!string.IsNullOrEmpty(cart.CouponCode))
{
var coupon = await _coupons
.ValidateCouponAsync(
cart.CouponCode, cart.UserId);
if (coupon != null)
{
var couponDiscount = coupon.Type
== DiscountType.Percentage
? (result.BaseTotal -
result.ProductDiscountTotal) *
coupon.Value / 100
: coupon.Value;
result.AppliedDiscounts.Add(
new AppliedDiscount
{
Code = coupon.Code,
Description =
$"{coupon.Value}% off",
Amount = couponDiscount,
AppliesTo = DiscountScope.Order
});
await _coupons
.IncrementUsageAsync(coupon.CouponId);
}
}
result.TotalDiscount = result.AppliedDiscounts
.Sum(d => d.Amount);
result.DiscountedSubtotal =
result.BaseTotal - result.TotalDiscount;
return result;
}
}
public class TaxService : ITaxService
{
private readonly ITaxProvider _provider;
public async Task<TaxResult> CalculateTaxAsync(
decimal amount, Address address,
List<CartItem> items)
{
var taxRequest = new TaxRequest
{
TransactionDate = DateTime.UtcNow,
Addresses = new[] { MapAddress(address) },
Lines = items.Select(i => new TaxLine
{
ItemCode = i.Sku,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
LineTotal = i.LineTotal,
TaxCode = GetTaxCode(i.ProductId)
}).ToList()
};
var response = await _provider
.CalculateTaxAsync(taxRequest);
return new TaxResult
{
TotalTax = response.TotalTax,
TaxBreakdown = response.TaxLines
.Select(l => new TaxBreakdownItem
{
Jurisdiction = l.JurisdictionName,
Rate = l.Rate,
TaxableAmount = l.TaxableAmount,
TaxAmount = l.TaxAmount
}).ToList()
};
}
}
Coupon Validation Rules
| Rule | Description | Error Response |
|---|---|---|
| Existence | Coupon code must exist and be active | "Invalid coupon code" |
| Expiration | Coupon must not be expired | "This coupon has expired" |
| Usage limit | Total uses must not exceed max_redemptions | "This coupon has reached its usage limit" |
| Per-user limit | User uses must not exceed per_user_limit | "You have already used this coupon" |
| Minimum order | Cart subtotal must meet minimum_order_amount | "Minimum order of $X required" |
| Product restriction | At least one eligible product in cart | "This coupon is not applicable to your cart" |
| Stackability | Coupon must not conflict with existing discounts | "This coupon cannot be combined with other offers" |
12. Checkout Flow & Address Validation
The checkout flow is an orchestrated sequence of steps that must guide the user from cart to confirmed order. Each step may involve external service calls, user input validation, and state management. The checkout service acts as an orchestrator, maintaining checkout state and coordinating calls to the inventory, pricing, shipping, tax, and payment services.
Checkout Step Sequence
Address Validation
Address validation is critical for reducing shipping errors and delivery failures. The system validates addresses using a multi-layer approach: format validation (USPS/Country-specific format rules), database lookup (existing known addresses), and external API validation (Google Places API or SmartyStreets for USPS address standardization). Validated addresses are cached to reduce API calls for repeat customers.
C#
public class AddressValidationService
: IAddressValidationService
{
private readonly IAddressValidator _validator;
private readonly IAddressCache _cache;
public async Task<AddressValidationResult> ValidateAsync(
Address address)
{
// 1. Check cache for previously validated address
var cacheKey = ComputeAddressHash(address);
var cached = await _cache.GetAsync(cacheKey);
if (cached != null)
return cached;
// 2. Format validation
var formatResult = ValidateFormat(address);
if (!formatResult.IsValid)
return formatResult;
// 3. External API validation (SmartyStreets / USPS)
var apiResult = await _validator
.StandardizeAsync(address);
var result = new AddressValidationResult
{
IsValid = apiResult.Deliverable,
StandardizedAddress = apiResult.Standardized,
Suggestions = apiResult.Candidates
.Take(3)
.Select(c => c.FormattedAddress)
.ToList(),
Resolution = apiResult.Deliverable
? AddressResolution.Verified
: AddressResolution.Undeliverable
};
if (result.IsValid)
{
await _cache.SetAsync(cacheKey, result,
TimeSpan.FromDays(30));
}
return result;
}
}
Checkout Orchestrator
C#
public class CheckoutOrchestrator : ICheckoutService
{
private readonly IInventoryService _inventory;
private readonly IPricingEngine _pricing;
private readonly IShippingService _shipping;
private readonly ITaxService _tax;
private readonly IPaymentService _payment;
private readonly IOrderService _orders;
private readonly IEventPublisher _events;
public async Task<CheckoutResult> InitializeCheckoutAsync(
Guid cartId, Guid? userId)
{
var cart = await LoadCartAsync(cartId);
if (!cart.Items.Any())
throw new ConflictException("Cart is empty");
// Parallel calls: inventory check + price calculation
var inventoryTask = _inventory
.CheckAvailabilityAsync(cart.Items);
var pricingTask = _pricing.CalculatePriceAsync(cart);
await Task.WhenAll(inventoryTask, pricingTask);
var inventory = await inventoryTask;
var pricing = await pricingTask;
if (!inventory.AllAvailable)
{
return new CheckoutResult
{
Success = false,
UnavailableItems = inventory.UnavailableItems
};
}
// Create checkout session
var checkout = new CheckoutSession
{
CheckoutId = Guid.NewGuid(),
CartId = cartId,
UserId = userId,
PricingResult = pricing,
Status = CheckoutStatus.AddressRequired,
ExpiresAt = DateTime.UtcNow.AddMinutes(15),
CreatedAt = DateTime.UtcNow
};
await _cache.SetCheckoutAsync(checkout,
TimeSpan.FromMinutes(15));
return new CheckoutResult
{
Success = true,
CheckoutId = checkout.CheckoutId,
Pricing = pricing,
Status = checkout.Status
};
}
public async Task<OrderResult> PlaceOrderAsync(
Guid checkoutId, PlaceOrderRequest request)
{
var checkout = await _cache
.GetCheckoutAsync(checkoutId);
if (checkout == null)
throw new NotFoundException(
"Checkout session expired");
// 1. Reserve inventory (soft lock, 10 min TTL)
var reservation = await _inventory
.ReserveInventoryAsync(
checkout.OrderId, checkout.Cart.Items);
if (!reservation.AllReserved)
{
return new OrderResult
{
Success = false,
Error = "Some items are no longer available",
UnavailableItems = reservation.Items
.Where(i => !i.Success).ToList()
};
}
// 2. Re-validate pricing (prices may have changed)
var currentPricing = await _pricing
.CalculatePriceAsync(checkout.Cart);
if (currentPricing.GrandTotal !=
checkout.PricingResult.GrandTotal)
{
return new OrderResult
{
Success = false,
Error = "Prices have changed",
UpdatedPricing = currentPricing
};
}
// 3. Process payment
var paymentResult = await _payment
.ChargeAsync(new PaymentRequest
{
Amount = checkout.PricingResult.GrandTotal,
Currency = checkout.Cart.Currency,
PaymentMethodId = request.PaymentMethodId,
IdempotencyKey = checkout.CheckoutId.ToString(),
Metadata = new()
{
["checkout_id"] =
checkout.CheckoutId.ToString(),
["cart_id"] =
checkout.CartId.ToString()
}
});
if (!paymentResult.Success)
{
await _inventory.ReleaseReservationAsync(
reservation.OrderId);
return new OrderResult
{
Success = false,
Error = paymentResult.ErrorMessage
};
}
// 4. Create order
var order = await _orders.CreateOrderAsync(
checkout, paymentResult, request);
// 5. Publish events (async, fire-and-forget)
await _events.PublishAsync(new OrderPlaced
{
OrderId = order.OrderId,
UserId = checkout.UserId,
Total = checkout.PricingResult.GrandTotal,
Timestamp = DateTime.UtcNow
});
// 6. Clear cart and checkout session
await ClearCartAsync(checkout.CartId);
await _cache.RemoveCheckoutAsync(checkoutId);
return new OrderResult
{
Success = true,
OrderId = order.OrderId,
OrderNumber = order.OrderNumber
};
}
}
13. Shipping Calculation & Carrier Integration
Shipping calculation involves integrating with multiple carrier APIs (UPS, FedEx, USPS, DHL) to provide accurate rates and delivery estimates. The shipping service must handle rate shopping (comparing prices across carriers), address validation (ensuring the address is deliverable), package dimension calculation, and delivery date estimation. This is one of the most latency-sensitive parts of checkout because it involves multiple external API calls.
Shipping Rate Response
JSON
{
"shipping_options": [
{
"carrier": "UPS",
"service": "Ground",
"rate_id": "ups_ground_001",
"rate": 8.99,
"currency": "USD",
"estimated_days": { "min": 5, "max": 7 },
"estimated_delivery": "2026-07-18",
"weight_kg": 0.45
},
{
"carrier": "FedEx",
"service": "Express 2Day",
"rate_id": "fedex_express_2day",
"rate": 14.99,
"currency": "USD",
"estimated_days": { "min": 2, "max": 2 },
"estimated_delivery": "2026-07-14",
"weight_kg": 0.45
},
{
"carrier": "USPS",
"service": "Priority Mail",
"rate_id": "usps_priority",
"rate": 7.35,
"currency": "USD",
"estimated_days": { "min": 2, "max": 3 },
"estimated_delivery": "2026-07-15",
"weight_kg": 0.45
}
],
"free_shipping_eligible": false,
"free_shipping_remaining": 15.02
}
Free Shipping Threshold Logic
Free shipping is a powerful conversion incentive. The system tracks each cart's proximity to the free shipping threshold and displays a dynamic banner (e.g., "Add $15.02 more for free shipping!"). This is calculated by comparing the cart subtotal against the store's free shipping threshold (typically $50-$100). When the threshold is met, the shipping options are filtered to show free shipping as the default option.
C#
public class ShippingService : IShippingService
{
private readonly ICarrierClient _carriers;
private readonly IFreeShippingConfig _config;
public async Task<ShippingQuote> GetRatesAsync(
Address destination, List<CartItem> items)
{
// 1. Calculate package dimensions and weight
var packages = PackageCalculator
.OptimizePacking(items);
// 2. Fetch rates from all carriers in parallel
var carrierTasks = new[]
{
_carriers.GetUpsRatesAsync(destination, packages),
_carriers.GetFedExRatesAsync(destination, packages),
_carriers.GetUspsRatesAsync(destination, packages)
};
var carrierResults = await Task
.WhenAll(carrierTasks);
// 3. Aggregate and deduplicate rates
var rates = carrierResults
.SelectMany(r => r.Rates)
.OrderBy(r => r.Rate)
.ToList();
// 4. Check free shipping eligibility
var subtotal = items.Sum(i => i.LineTotal);
var freeShippingThreshold =
_config.FreeShippingThreshold;
var qualifiesForFreeShipping =
subtotal >= freeShippingThreshold;
if (qualifiesForFreeShipping)
{
rates.Insert(0, new ShippingRate
{
Carrier = "FREE",
Service = "Standard Shipping",
Rate = 0m,
EstimatedDays = new() { Min = 5, Max = 7 }
});
}
return new ShippingQuote
{
Options = rates,
FreeShippingEligible =
qualifiesForFreeShipping,
FreeShippingRemaining =
qualifiesForFreeShipping
? 0m
: freeShippingThreshold - subtotal
};
}
}
14. Payment Processing & Stripe Integration
Payment processing is the highest-risk component of the checkout system. A bug in payment processing can result in double charges, lost payments, or financial data exposure. The payment service must handle authorization, capture, 3D Secure authentication, payment method vaulting, refunds, and disputes — all while maintaining PCI-DSS compliance. We use Stripe as our primary payment gateway, which provides a robust API, built-in fraud detection (Radar), and support for 135+ currencies.
Payment Flow
Stripe Payment Integration
C#
public class StripePaymentService : IPaymentService
{
private readonly StripeClient _stripe;
private readonly IPaymentRepository _repo;
public async Task<PaymentResult> ChargeAsync(
PaymentRequest request)
{
// 1. Create payment record for audit trail
var payment = new PaymentRecord
{
PaymentId = Guid.NewGuid(),
IdempotencyKey = request.IdempotencyKey,
Amount = request.Amount,
Currency = request.Currency,
Status = PaymentStatus.Pending,
CreatedAt = DateTime.UtcNow
};
await _repo.CreateAsync(payment);
try
{
// 2. Create Stripe PaymentIntent
var intentOptions = new PaymentIntentCreateOptions
{
Amount = StripeAmount(request.Amount,
request.Currency),
Currency = request.Currency.ToLower(),
PaymentMethod = request.PaymentMethodId,
ConfirmationMethod = "manual",
Confirm = true,
CaptureMethod = "manual",
IdempotencyKey = request.IdempotencyKey,
Metadata = request.Metadata
.ToDictionary(k => k.Key, k => k.Value)
};
var intent = await _stripe.PaymentIntents
.CreateAsync(intentOptions);
payment.StripePaymentIntentId = intent.Id;
payment.StripeStatus = intent.Status;
// 3. Handle response
switch (intent.Status)
{
case "succeeded":
payment.Status = PaymentStatus.Authorized;
await _repo.UpdateAsync(payment);
return new PaymentResult
{
Success = true,
PaymentId = payment.PaymentId,
AuthorizationId = intent.Id
};
case "requires_action":
payment.Status =
PaymentStatus.RequiresAction;
await _repo.UpdateAsync(payment);
return new PaymentResult
{
Success = false,
RequiresAction = true,
ClientSecret = intent.ClientSecret,
PaymentId = payment.PaymentId
};
case "requires_payment_method":
payment.Status = PaymentStatus.Failed;
payment.FailureReason =
"Invalid payment method";
await _repo.UpdateAsync(payment);
return new PaymentResult
{
Success = false,
ErrorMessage =
"Invalid payment method"
};
default:
payment.Status = PaymentStatus.Failed;
payment.FailureReason = intent.Status;
await _repo.UpdateAsync(payment);
return new PaymentResult
{
Success = false,
ErrorMessage =
"Payment could not be processed"
};
}
}
catch (StripeException ex)
{
payment.Status = PaymentStatus.Failed;
payment.FailureReason = ex.Message;
await _repo.UpdateAsync(payment);
throw new PaymentException(
"Payment processing failed", ex);
}
}
public async Task<CaptureResult> CaptureAsync(
Guid paymentId)
{
var payment = await _repo.GetAsync(paymentId);
if (payment == null)
throw new NotFoundException("Payment not found");
var captureOptions = new PaymentIntentCaptureOptions
{
AmountToCapture = StripeAmount(
payment.Amount, payment.Currency)
};
var intent = await _stripe.PaymentIntents
.CaptureAsync(
payment.StripePaymentIntentId,
captureOptions);
payment.Status = PaymentStatus.Captured;
payment.CapturedAt = DateTime.UtcNow;
await _repo.UpdateAsync(payment);
return new CaptureResult
{
Success = true,
CapturedAmount = payment.Amount
};
}
private long StripeAmount(decimal amount, string currency)
{
var zeroDecimalCurrencies = new HashSet<string>
{ "JPY", "KRW", "VND" };
var divisor = zeroDecimalCurrencies
.Contains(currency.ToUpper()) ? 1 : 100;
return (long)(amount * divisor);
}
}
15. Idempotency & Exactly-Once Payments
In a distributed system, network failures can cause requests to be retried. Without idempotency, a retry could result in a double charge — the most financially damaging type of system failure. The payment service must guarantee exactly-once processing of every payment request, even if the client retries due to a timeout or network error.
Idempotency Strategy
Every payment request includes an idempotency_key — a unique identifier generated by the client (typically the checkout session ID). The payment service stores a mapping of idempotency_key to payment_result in PostgreSQL with a unique constraint. Before processing a new payment, the service checks if the idempotency key already exists. If it does, it returns the stored result instead of processing a new payment.
C#
public class IdempotentPaymentService : IPaymentService
{
private readonly IPaymentService _inner;
private readonly IPaymentRepository _repo;
private readonly IDistributedLock _locks;
public async Task<PaymentResult> ChargeAsync(
PaymentRequest request)
{
if (string.IsNullOrEmpty(request.IdempotencyKey))
throw new ArgumentException(
"Idempotency key is required");
// Acquire lock for this idempotency key
var lockKey =
$"idempotency:{request.IdempotencyKey}";
await _locks.AcquireAsync(lockKey,
TimeSpan.FromSeconds(30));
try
{
// Check if this key was already processed
var existing = await _repo
.GetByIdeMPotencyKeyAsync(
request.IdempotencyKey);
if (existing != null)
{
// Return cached result — do NOT process again
return new PaymentResult
{
Success = existing.Status ==
PaymentStatus.Authorized,
PaymentId = existing.PaymentId,
AuthorizationId =
existing.StripePaymentIntentId,
IdempotentReplay = true
};
}
// Process the payment
var result = await _inner.ChargeAsync(request);
return result;
}
finally
{
await _locks.ReleaseAsync(lockKey);
}
}
}
Timeout Handling and Reconciliation
The most dangerous scenario in payment processing is the "uncertain state" — where the client sends a payment request, the payment gateway processes it, but the response is lost due to a network timeout. The client does not know if the payment succeeded, and the server may have already charged the customer. Our system handles this through a reconciliation process that runs in the background.
C#
public class PaymentReconciliationWorker : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var pendingPayments = await _repo
.GetPendingPaymentsAsync(
olderThan: TimeSpan.FromSeconds(60));
foreach (var payment in pendingPayments)
{
try
{
var status = await _stripe
.PaymentIntents.GetAsync(
payment.StripePaymentIntentId);
payment.StripeStatus = status.Status;
payment.Status = status.Status == "succeeded"
? PaymentStatus.Authorized
: PaymentStatus.Failed;
payment.ReconciledAt = DateTime.UtcNow;
await _repo.UpdateAsync(payment);
_log.LogInformation(
"Reconciled payment {Id}: {Status}",
payment.PaymentId, status.Status);
}
catch (Exception ex)
{
_log.LogError(ex,
"Failed to reconcile payment {Id}",
payment.PaymentId);
}
}
await Task.Delay(
TimeSpan.FromSeconds(30), stoppingToken);
}
}
}
16. Order State Machine
Every order progresses through a well-defined lifecycle of states. The order state machine ensures that orders transition correctly and that invalid state transitions are rejected. This state machine is the single source of truth for order status and drives all downstream processes (payment capture, fulfillment, notifications). A clean state machine makes the system predictable, debuggable, and auditable.
Order States and Transitions
Order State Machine Implementation
C#
public class OrderStateMachine
{
private static readonly Dictionary<
OrderStatus, HashSet<OrderStatus>> Transitions = new()
{
[OrderStatus.Pending] = new()
{ OrderStatus.Confirmed, OrderStatus.Cancelled },
[OrderStatus.Confirmed] = new()
{ OrderStatus.Processing, OrderStatus.Cancelled },
[OrderStatus.Processing] = new()
{ OrderStatus.Shipped, OrderStatus.Cancelled },
[OrderStatus.Shipped] = new()
{ OrderStatus.Delivered, OrderStatus.Returned },
[OrderStatus.Delivered] = new()
{ OrderStatus.Completed, OrderStatus.Refunded },
[OrderStatus.Returned] = new()
{ OrderStatus.Refunded, OrderStatus.PartialRefund }
};
public bool CanTransition(
OrderStatus from, OrderStatus to)
{
return Transitions.TryGetValue(from, out var allowed)
&& allowed.Contains(to);
}
public OrderStatus Transition(
OrderStatus current, OrderStatus target)
{
if (!CanTransition(current, target))
{
throw new InvalidOperationException(
$"Invalid order transition: " +
$"{current} -> {target}");
}
return target;
}
}
public class OrderService : IOrderService
{
private readonly OrderStateMachine _stateMachine;
private readonly IOrderRepository _repo;
private readonly IEventPublisher _events;
public async Task<Order> CreateOrderAsync(
CheckoutSession checkout,
PaymentResult payment,
PlaceOrderRequest request)
{
var order = new Order
{
OrderId = Guid.NewGuid(),
OrderNumber = GenerateOrderNumber(),
UserId = checkout.UserId,
Status = OrderStatus.Pending,
Subtotal = checkout.PricingResult.Subtotal,
DiscountTotal =
checkout.PricingResult.TotalDiscount,
TaxTotal = checkout.PricingResult.TaxTotal,
ShippingTotal =
checkout.PricingResult.ShippingTotal,
GrandTotal =
checkout.PricingResult.GrandTotal,
Currency = checkout.Cart.Currency,
ShippingAddress = request.ShippingAddress,
BillingAddress = request.BillingAddress,
ShippingMethod = request.ShippingMethod,
PaymentMethod = request.PaymentMethodId,
PaymentIntentId =
payment.AuthorizationId,
IdempotencyKey =
checkout.CheckoutId.ToString(),
PlacedAt = DateTime.UtcNow,
Items = checkout.Cart.Items.Select(i =>
new OrderItem
{
OrderItemId = Guid.NewGuid(),
ProductId = i.ProductId,
VariantId = i.VariantId,
Sku = i.Sku,
Name = i.Name,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
LineTotal = i.LineTotal
}).ToList()
};
// Transition: Pending -> Confirmed
order.Status = _stateMachine.Transition(
OrderStatus.Pending, OrderStatus.Confirmed);
await _repo.CreateOrderAsync(order);
await _events.PublishAsync(new OrderStateChanged
{
OrderId = order.OrderId,
From = OrderStatus.Pending,
To = OrderStatus.Confirmed,
Timestamp = DateTime.UtcNow
});
return order;
}
private string GenerateOrderNumber()
{
// Format: ORD-YYYYMMDD-XXXXX
var date = DateTime.UtcNow.ToString("yyyyMMdd");
var sequence = Interlocked.Increment(
ref _sequenceCounter);
return $"ORD-{date}-{sequence:D5}";
}
}
17. Order Confirmation & Email Receipts
Order confirmation is a multi-step process that provides the user with immediate feedback and sends a detailed receipt via email. The confirmation must include the order summary, payment confirmation, estimated delivery date, and next steps. The email must be sent reliably (using a transactional email service with delivery tracking) and must include the complete order details for the customer records.
Confirmation Flow
Order Confirmation Email Template
C#
public class OrderConfirmationHandler
: IEventHandler<OrderPlaced>
{
private readonly IEmailService _email;
private readonly ITemplateEngine _templates;
public async Task HandleAsync(OrderPlaced orderEvent)
{
var order = await _orderRepo
.GetOrderAsync(orderEvent.OrderId);
var templateData = new OrderConfirmationModel
{
OrderNumber = order.OrderNumber,
OrderDate = order.PlacedAt
.ToString("MMMM dd, yyyy"),
CustomerName = order.User.FirstName,
Items = order.Items.Select(i => new
{
Name = i.Name,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice.ToString("C"),
LineTotal = i.LineTotal.ToString("C")
}),
Subtotal = order.Subtotal.ToString("C"),
DiscountTotal = order.DiscountTotal.ToString("C"),
TaxTotal = order.TaxTotal.ToString("C"),
ShippingTotal = order.ShippingTotal.ToString("C"),
GrandTotal = order.GrandTotal.ToString("C"),
ShippingAddress = FormatAddress(
order.ShippingAddress),
EstimatedDelivery = CalculateEstimatedDelivery(
order.ShippingMethod),
TrackingUrl =
$"https://example.com/track/{order.OrderNumber}"
};
var html = await _templates.RenderAsync(
"order-confirmation", templateData);
await _email.SendAsync(new EmailMessage
{
To = order.User.Email,
Subject =
$"Order Confirmed — {order.OrderNumber}",
HtmlBody = html,
Tags = new()
{
"order-confirmation",
order.OrderNumber
}
});
}
}
Email Delivery Tracking
| Event | Source | Action |
|---|---|---|
| Delivered | SendGrid webhook | Log success, no further action |
| Opened | SendGrid webhook | Track engagement, update analytics |
| Clicked | SendGrid webhook | Track link clicks (tracking URL) |
| Bounced | SendGrid webhook | Mark email invalid, alert support |
| Spam report | SendGrid webhook | Flag user, stop marketing emails |
| Deferred | SendGrid webhook | Log, no action (temporary issue) |
18. Cart Abandonment Recovery
Cart abandonment recovery is one of the highest-ROI features in e-commerce. According to Moosend research, sending abandoned cart emails can recover approximately 10-15% of lost revenue. The system must track when users abandon their carts, trigger recovery emails at optimal intervals, and provide deep links that restore the user's exact cart state. For a platform processing $750M/month in GMV with a 70% abandonment rate, recovering even 5% of abandoned carts represents approximately $26.25M in recovered revenue monthly.
Abandonment Detection and Recovery Flow
Abandonment Tracking Implementation
C#
public class CartAbandonmentTracker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var carts = scope.ServiceProvider
.GetRequiredService<ICartRepository>();
var events = scope.ServiceProvider
.GetRequiredService<IEventPublisher>();
// Find carts that have been idle for 30+ minutes
// and have items but no checkout started
var abandonedCarts = await carts
.GetAbandonedCartsAsync(
idleThreshold:
TimeSpan.FromMinutes(30),
batchSize: 500);
foreach (var cart in abandonedCarts)
{
// Only track if we have an email address
if (string.IsNullOrEmpty(
cart.User?.Email))
continue;
// Mark as abandoned
cart.Status = CartStatus.Abandoned;
await carts.UpdateCartAsync(cart);
// Emit abandonment event
await events.PublishAsync(
new CartAbandoned
{
CartId = cart.CartId,
UserId = cart.UserId,
Email = cart.User.Email,
ItemCount = cart.Items.Count,
CartValue = cart.Totals.GrandTotal,
AbandonedAt = DateTime.UtcNow,
CartUrl =
$"https://example.com/cart" +
$"/recover/{cart.CartId}"
});
}
await Task.Delay(
TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
public class AbandonmentEmailHandler
: IEventHandler<CartAbandoned>
{
private readonly IEmailService _email;
public async Task HandleAsync(CartAbandoned abandoned)
{
// Email 1: "You left something behind" (1 hour)
var delay = TimeSpan.FromHours(1);
await _email.ScheduleAsync(new ScheduledEmail
{
To = abandoned.Email,
Subject = "You left items in your cart",
Template = "cart-abandonment-1",
Data = new
{
Items = abandoned.CartValue,
CartUrl = abandoned.CartUrl
},
SendAt = DateTime.UtcNow.Add(delay),
CancelIf = async () =>
{
// Cancel if cart was converted to order
var cart = await _cartRepo
.GetCartAsync(abandoned.CartId);
return cart?.Status ==
CartStatus.Converted;
}
});
}
}
Recovery Email Strategy
| Timing | Content | Expected Recovery Rate | |
|---|---|---|---|
| Reminder | 1 hour | Cart items, no discount | 5-8% |
| Follow-up | 24 hours | Cart items + social proof reviews | 3-5% |
| Last chance | 72 hours | Cart items + 10% discount code | 2-3% |
| Final | 7 days | Urgency ("items selling fast") + free shipping | 1-2% |
19. A/B Testing Checkout Flows
The checkout flow is one of the most impactful areas for A/B testing in e-commerce. Small changes to the checkout flow — the number of steps, the placement of trust badges, the order of payment options — can significantly impact conversion rates. The system must support running multiple checkout flow variants simultaneously with proper statistical rigor and experiment isolation.
Checkout Experiment Framework
C#
public class CheckoutExperimentService
{
private readonly IFeatureFlagService _flags;
private readonly IExperimentTracker _tracker;
public CheckoutVariant GetVariant(
Guid userId, Guid sessionId)
{
// Deterministic variant assignment
// using consistent hashing
var hash = ComputeHash(
$"checkout-experiment-2026-" +
$"{userId ?? sessionId}");
var bucket = hash % 100;
var variant = bucket < 50
? CheckoutVariant.Control // Multi-step
: CheckoutVariant.Treatment; // Single-page
_tracker.TrackAssignment(new ExperimentAssignment
{
ExperimentName = "checkout-flow-v2",
UserId = userId,
SessionId = sessionId,
Variant = variant.ToString(),
AssignedAt = DateTime.UtcNow
});
return variant;
}
}
// Variants
public enum CheckoutVariant
{
Control, // Traditional 4-step checkout
Treatment // Single-page checkout with accordion
}
// In the checkout controller
[HttpGet("checkout")]
public async Task<IActionResult> Checkout()
{
var variant = _experiments.GetVariant(
CurrentUserId, SessionId);
return variant switch
{
CheckoutVariant.Control =>
View("Checkout_MultiStep"),
CheckoutVariant.Treatment =>
View("Checkout_SinglePage"),
_ => View("Checkout_MultiStep")
};
}
Key Metrics to Track
| Metric | Description | Target Impact |
|---|---|---|
| Conversion Rate | % of checkout initiations that result in an order | Primary metric |
| Cart-to-Checkout Rate | % of users who start checkout from cart | Funnel step |
| Checkout Step Drop-off | % of users abandoning at each step | Diagnostic |
| Time to Complete | Seconds from checkout start to order placed | UX quality |
| Payment Failure Rate | % of payment attempts that fail | Reliability |
| Average Order Value | Mean grand_total per order | Revenue impact |
| Revenue per Session | Total revenue / total checkout sessions | Business outcome |
Statistical Significance
Checkout experiments require careful statistical handling because conversion rates are typically low (2-5%) and order values are high-variance. A typical checkout A/B test needs 10,000-50,000 completed orders per variant to detect a 1% relative improvement in conversion rate with 95% confidence. At this sample size, the test typically runs for 2-4 weeks. We use a Bayesian approach with Thompson Sampling for early stopping when the probability of one variant being better exceeds 95%, allowing faster iteration on losing variants.
20. Performance Optimization
Checkout performance directly impacts conversion rates. Amazon found that every 100ms of additional latency cost them 1% in sales. Google found that a 0.5-second delay in search results reduced traffic by 20%. For e-commerce checkout, the stakes are even higher — users who encounter slow checkout pages are highly likely to abandon their carts entirely and never return. Performance optimization must be approached systematically, measuring and improving each component of the checkout latency pipeline.
Optimization Strategies
| Strategy | Implementation | Impact |
|---|---|---|
| Parallel service calls | Execute inventory, pricing, shipping, tax calls concurrently | Reduce checkout latency from 800ms to 250ms |
| Redis cart cache | Hot cart data in Redis, fallback to PostgreSQL on miss | Cart reads: 15ms to 1ms |
| Prefetch shipping rates | Pre-calculate shipping rates for popular addresses | Reduce shipping call from 200ms to 20ms |
| Tax calculation caching | Cache tax rates by (jurisdiction, product_type) | Reduce tax call from 150ms to 5ms |
| Inventory count caching | Redis cache for stock counts, 30s TTL | Reduce inventory check from 100ms to 2ms |
| Pricing rule caching | Compile pricing rules to in-memory functions | Reduce pricing calc from 50ms to 5ms |
| Connection pooling | Pre-established connections to all services | Eliminate TCP/TLS handshake overhead |
| Response compression | Brotli compression for API responses | Reduce payload size by 60-70% |
Checkout Latency Budget
| Step | Target Latency | Method |
|---|---|---|
| Cart load (Redis) | 2ms | Direct Redis GET |
| Inventory check | 10ms | Redis-cached stock counts |
| Price calculation | 15ms | In-memory rule engine |
| Tax calculation | 10ms | Cached tax rates |
| Shipping rates | 30ms | Prefetched + cached |
| Inventory reservation | 15ms | Optimistic lock + retry |
| Payment authorization | 200ms | Stripe API (external) |
| Order creation | 20ms | PostgreSQL INSERT |
| Total | ~300ms | Parallel execution where possible |
Database Optimization
SQL
-- Partition orders by month for query performance
CREATE TABLE orders (
-- ... columns as before ...
) PARTITION BY RANGE (placed_at);
CREATE TABLE orders_2026_07 PARTITION OF orders
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
-- Composite index for the most common query pattern
CREATE INDEX idx_orders_user_status_placed
ON orders(user_id, status, placed_at DESC);
-- Partial index for active orders only
CREATE INDEX idx_orders_active
ON orders(placed_at DESC)
WHERE status NOT IN ('cancelled', 'refunded', 'completed');
-- Materialized view for analytics dashboard
CREATE MATERIALIZED VIEW order_daily_summary AS
SELECT
DATE_TRUNC('day', placed_at) AS day,
COUNT(*) AS order_count,
SUM(grand_total) AS revenue,
AVG(grand_total) AS avg_order_value,
COUNT(DISTINCT user_id) AS unique_customers
FROM orders
WHERE status != 'cancelled'
GROUP BY DATE_TRUNC('day', placed_at)
ORDER BY day DESC;
21. Security, PCI DSS & Compliance
The checkout system handles highly sensitive data: credit card numbers, personal addresses, and financial transactions. Security must be a first-class concern at every layer of the architecture. The system must comply with PCI-DSS (Payment Card Industry Data Security Standard) for card data handling, GDPR/CCPA for personal data privacy, and SOC 2 for operational security.
PCI-DSS Compliance Levels
| Level | Annual Transactions | Requirements | Our Approach |
|---|---|---|---|
| Level 1 | > 6 million | Annual on-site audit + quarterly ASV scan | Full PCI-DSS compliance program |
| Level 2 | 1-6 million | Annual SAQ + quarterly ASV scan | SAQ A-EP compliance |
| Level 3 | 20K-1M | Annual SAQ | SAQ A compliance |
| Level 4 | < 20K | SAQ recommended | Minimal requirements |
Security Architecture
Security Measures
| Layer | Measure | Implementation |
|---|---|---|
| Transport | TLS 1.3 everywhere | All internal and external communication encrypted |
| Authentication | JWT with short expiry | 15-minute access tokens, refresh token rotation |
| Authorization | Role-based + resource-based | Users can only access their own carts/orders |
| Input validation | Server-side validation on all inputs | FluentValidation for all API endpoints |
| Rate limiting | Per-user and per-IP rate limits | Token bucket algorithm at API Gateway |
| Data encryption | AES-256 at rest | Transparent data encryption for PostgreSQL |
| Card data | Tokenization via Stripe | Card numbers never touch our servers |
| Audit logging | All state changes logged | Immutable audit trail in append-only table |
| Secrets management | HashiCorp Vault | No secrets in code, config, or env vars |
| Vulnerability scanning | Continuous | Snyk for dependencies, Trivy for containers |
GDPR/CCPA Data Handling
C#
public class DataPrivacyService : IDataPrivacyService
{
// GDPR: Right to Erasure (Article 17)
public async Task<DeletionResult> DeleteUserDataAsync(
Guid userId)
{
// 1. Anonymize completed orders
await _orders.AnonymizeUserAsync(userId);
// 2. Delete all active carts
await _carts.DeleteUserCartsAsync(userId);
// 3. Delete personal data from profiles
await _users.DeletePersonalDataAsync(userId);
// 4. Remove from marketing lists
await _marketing.UnsubscribeAsync(userId);
// 5. Log deletion for audit
await _audit.LogAsync(new DataDeletionEvent
{
UserId = userId,
DeletedAt = DateTime.UtcNow,
RetainedData = new[]
{
"anonymized_orders (financial reporting)"
}
});
return new DeletionResult
{
Success = true,
DeletedAt = DateTime.UtcNow
};
}
// GDPR: Right to Data Portability (Article 20)
public async Task<UserDataExport> ExportUserDataAsync(
Guid userId)
{
var user = await _users.GetAsync(userId);
var orders = await _orders
.GetUserOrdersAsync(userId);
var carts = await _carts
.GetUserCartHistoryAsync(userId);
return new UserDataExport
{
Profile = user,
Orders = orders,
CartHistory = carts,
ExportedAt = DateTime.UtcNow,
Format = "JSON"
};
}
}
22. Monitoring, Observability & Cost Estimation
The checkout system must be deeply observable because failures directly impact revenue. Every component must emit metrics, logs, and traces that allow operators to quickly identify and resolve issues. A 5-minute undetected checkout outage during peak hours can cost thousands of dollars in lost sales. The monitoring strategy must cover infrastructure health, application performance, business metrics, and external dependency status.
Key Metrics Dashboard
| Metric | Type | Alert Threshold | Dashboard Panel |
|---|---|---|---|
| Cart API p99 latency | Histogram | > 200ms for 5 min | Latency heatmap |
| Checkout API p99 latency | Histogram | > 1000ms for 5 min | Latency heatmap |
| Cart conversion rate | Counter | < 2% (hourly) for 30 min | Conversion funnel |
| Payment success rate | Counter | < 95% for 10 min | Payment health |
| Orders per second | Gauge | < 50% of expected (anomaly) | Throughput graph |
| Cart abandonment rate | Counter | > 80% (hourly) for 2 hours | Abandonment trend |
| Inventory reservation conflicts | Counter | > 100/min for 5 min | Conflict rate |
| Stripe API error rate | Counter | > 5% for 5 min | Payment errors |
| Redis cache hit rate | Gauge | < 95% | Cache performance |
| Cart TTL expiry rate | Counter | Anomaly detection | Cart lifecycle |
Distributed Tracing
Every checkout request is traced from the initial API call through all service hops to the final order creation. We use OpenTelemetry for instrumentation and Jaeger for trace collection. A single checkout request trace might include the following hops:
Text
TraceID: abc123def456
|-- [12ms] POST /api/v1/checkout/{id}/payment
| |-- [2ms] Auth middleware (JWT validation)
| |-- [5ms] Redis: GET checkout:{id}
| |-- [3ms] Redis: GET cart:{cartId}
| |-- PARALLEL:
| | |-- [8ms] gRPC: InventoryService.Reserve()
| | | |-- [5ms] PostgreSQL: INSERT reservation
| | |-- [12ms] gRPC: PricingEngine.Recalculate()
| | | |-- [3ms] Redis: GET pricing-rules
| | |-- [45ms] HTTP: Stripe PaymentIntent.create()
| | |-- [15ms] gRPC: TaxService.Calculate()
| | |-- [10ms] HTTP: Avalara API
| |-- [8ms] PostgreSQL: INSERT order
| |-- [2ms] Kafka: PUBLISH OrderPlaced
| |-- [1ms] Redis: DEL checkout:{id}
|-- Total: 95ms
Cost Estimation
| Component | Specification | Monthly Cost |
|---|---|---|
| Cart API (ECS Fargate) | 6 tasks x 2 vCPU, 4GB RAM | $520 |
| Checkout API (ECS Fargate) | 4 tasks x 2 vCPU, 4GB RAM | $347 |
| Order API (ECS Fargate) | 3 tasks x 2 vCPU, 4GB RAM | $260 |
| Redis Cluster | 6 nodes x r6g.xlarge (26GB) | $1,800 |
| PostgreSQL (RDS) | db.r6g.2xlarge, Multi-AZ, 1TB | $1,400 |
| Kafka (MSK) | 3 brokers x kafka.m5.large | $450 |
| Stripe fees | 15M transactions x 2.9% + $0.30 | Variable (per-transaction) |
| S3 (logs + backups) | 5TB storage | $115 |
| CloudWatch + X-Ray | Logs + traces + dashboards | $300 |
| WAF + Shield | DDoS protection + WAF rules | $150 |
| Total Infrastructure | ~$5,342 |
Alerting Runbook
| Alert | Severity | First Response | Escalation |
|---|---|---|---|
| Checkout p99 > 1s for 5min | P1 (Critical) | Check Stripe status page, check Redis connectivity | Page on-call if Redis/Stripe down |
| Payment success rate < 95% | P1 | Check Stripe dashboard for merchant-level issues | Contact Stripe support |
| Cart API p99 > 200ms | P2 | Check Redis memory/connection count | Scale Redis cluster if needed |
| Inventory oversell detected | P1 | Pause inventory reservations, investigate race condition | Hotfix required |
| Order count drop > 50% | P1 | Check for service degradation, DNS issues | Full incident response |
23. Testing Strategy
The checkout system requires comprehensive testing at multiple levels because failures directly impact revenue. A checkout bug that causes 1% of payments to fail on a platform processing $750M/month in GMV costs approximately $7.5M/month in lost revenue (at 2.9% take rate). Testing must cover functional correctness, performance under load, failure scenarios, and integration with external services. The testing strategy follows the testing pyramid with emphasis on integration tests that verify service interactions.
Testing Pyramid
| Level | Type | Coverage Target | Tools |
|---|---|---|---|
| Unit | Individual service logic | 90%+ code coverage | xUnit, Moq, FluentAssertions |
| Integration | Service-to-service interactions | All critical paths | Testcontainers (PostgreSQL, Redis) |
| Contract | API contract verification | All external API boundaries | Pact, WireMock |
| E2E | Full checkout flow | All checkout scenarios | Playwright, Cypress |
| Load | Performance under load | Peak traffic simulation | k6, Gatling |
| Chaos | Failure injection | All critical failure modes | Chaos Monkey, Polly |
Integration Test Example
C#
public class CheckoutFlowTests
: IClassFixture<TestDatabase>
{
private readonly TestDatabase _db;
private readonly TestRedis _redis;
private readonly HttpClient _client;
public CheckoutFlowTests(
TestDatabase db, TestRedis redis)
{
_db = db;
_redis = redis;
_client = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddScoped<IDbConnection>(_ =>
_db.GetConnection());
});
}).CreateClient();
}
[Fact]
public async Task CompleteCheckoutFlow_ShouldCreateOrder()
{
// Arrange: Create cart with items
var cart = await CreateCartWithItemsAsync(
new[] { ("SKU-001", 2), ("SKU-002", 1) });
// Act: Initialize checkout
var initResponse = await _client.PostAsJsonAsync(
"/api/v1/checkout/initialize",
new { cart_id = cart.CartId });
initResponse.EnsureSuccessStatusCode();
var checkout = await initResponse.Content
.ReadFromJsonAsync<CheckoutInitResponse>();
// Act: Set shipping address
var addressResponse = await _client
.PutAsJsonAsync(
$"/api/v1/checkout/{checkout.CheckoutId}/address",
new
{
street = "123 Main St",
city = "San Francisco",
state = "CA",
zip = "94102",
country = "US"
});
addressResponse.EnsureSuccessStatusCode();
// Act: Select shipping method
var shippingResponse = await _client
.PutAsJsonAsync(
$"/api/v1/checkout/{checkout.CheckoutId}/shipping",
new { shipping_method = "ups_ground" });
shippingResponse.EnsureSuccessStatusCode();
// Act: Place order with test payment
var orderResponse = await _client.PostAsJsonAsync(
$"/api/v1/checkout/{checkout.CheckoutId}/payment",
new
{
payment_method_id = "pm_card_visa",
shipping_address = new { /* ... */ },
billing_address = new { /* ... */ }
});
// Assert
orderResponse.EnsureSuccessStatusCode();
var order = await orderResponse.Content
.ReadFromJsonAsync<OrderResponse>();
Assert.NotNull(order.OrderId);
Assert.Equal("confirmed", order.Status);
// Verify inventory was reserved
var inventory = await _db.QueryAsync
<InventoryReservation>(
"SELECT * FROM inventory_reservations " +
"WHERE order_id = @id",
new { id = order.OrderId });
Assert.Equal(2, inventory.Count());
// Verify cart was cleared
var cartAfter = await _redis
.GetCartAsync(cart.CartId);
Assert.Null(cartAfter);
}
}
Load Testing Scenario
JavaScript
// k6 load test for checkout flow
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
checkout_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 100 }, // Ramp up
{ duration: '5m', target: 500 }, // Sustained
{ duration: '2m', target: 1000 }, // Peak
{ duration: '3m', target: 1000 }, // Hold peak
{ duration: '2m', target: 0 }, // Ramp down
],
},
},
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
// 1. Add items to cart
const cartRes = http.post(
`${BASE_URL}/api/v1/carts/${cartId}/items`,
JSON.stringify({
product_id: 'prod_8xk2m9',
quantity: Math.floor(Math.random() * 3) + 1
}),
{ headers: { 'Content-Type': 'application/json' } }
);
check(cartRes, {
'cart add 200': (r) => r.status === 200
});
// 2. Initialize checkout
const initRes = http.post(
`${BASE_URL}/api/v1/checkout/initialize`,
JSON.stringify({ cart_id: cartId }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(initRes, {
'checkout init 200': (r) => r.status === 200
});
sleep(1);
// 3. Place order
const orderRes = http.post(
`${BASE_URL}/api/v1/checkout/${checkoutId}/payment`,
JSON.stringify({
payment_method_id: 'pm_card_visa',
shipping_address: testAddress
}),
{ headers: { 'Content-Type': 'application/json' } }
);
check(orderRes, {
'order placed': (r) => r.status === 200
});
}
24. Interview Q&A Deep Dive
Core Concepts
Q: How would you handle a scenario where a user adds 5 items to their cart, but by the time they reach checkout, 2 items are out of stock?
A: The system handles this at two levels. First, during checkout initialization, we check real-time inventory for all cart items and return an unavailable items list. The user sees which items are out of stock and can choose to proceed without them or go back to the cart. Second, if inventory goes to zero after checkout starts (between initialization and payment), the inventory reservation step will fail for those items, and the order placement returns an error with the specific unavailable items. The user must update their cart to remove the unavailable items before retrying. This graceful degradation approach is better than rejecting the entire checkout or silently removing items without the user's knowledge. We also send a real-time notification (via WebSocket) if an item becomes unavailable while the user is browsing, prompting them to update their cart before starting checkout.
Q: Design the inventory reservation system to prevent overselling during a flash sale with 100,000 concurrent users competing for 1,000 units of a product.
A: This is a classic contention problem. The naive approach (read-then-write) fails because the read and write are not atomic. The solution uses a distributed lock per SKU: all inventory operations for a given SKU are serialized through a Redis lock (SET NX EX with 5-second TTL). During the flash sale, this lock becomes a bottleneck, so we use several optimizations: (1) Pre-reserve inventory in batches — instead of reserving 1 unit at a time, reserve in blocks of 10-50 units and manage the sub-allocation in-memory. (2) Use optimistic concurrency with a version column — attempt the reservation, and if the version has changed, retry with exponential backoff. (3) Implement a queue — flash sale items go through a virtual queue where requests are processed in FIFO order, ensuring fairness. (4) Pre-warm Redis inventory counts before the flash sale starts to avoid cache miss storms. (5) Use a separate flash-sale service that handles the high contention path independently from the normal checkout flow, with its own database connection pool and Redis cluster.
Q: How would you design the cart merge flow when an anonymous user logs in with an existing authenticated cart?
A: Cart merging requires careful handling to avoid data loss or confusion. The merge algorithm: (1) Load both the anonymous cart and the user's existing cart. (2) For each item in the anonymous cart, check if the same product+variant already exists in the user's cart. If it does, sum the quantities (respecting per-product quantity limits). If not, add the item to the user's cart. (3) Preserve the most recently applied coupon from either cart (the authenticated cart's coupon takes precedence if both have one). (4) Recalculate all totals on the merged cart. (5) Mark the anonymous cart as "merged" (not deleted — we keep it for analytics). (6) Update Redis and PostgreSQL atomically. (7) Remove the anonymous cart from Redis cache. The merge is idempotent — calling it multiple times with the same anonymous cart produces the same result because the anonymous cart is marked as merged after the first merge and subsequent merges are no-ops.
Q: Explain the difference between authorization and capture in payment processing and why we use manual capture.
A: Authorization verifies that the customer's payment method is valid and has sufficient funds, and places a hold on the funds. Capture actually transfers the money from the customer's account to the merchant's account. We use manual capture (authorize first, capture later) because it provides a safety window between payment and fulfillment. If we authorized and captured immediately, we would have charged the customer even if we later discovered we could not fulfill the order (e.g., inventory reservation expired, shipping address is undeliverable, the order was flagged for fraud review). With manual capture, we can void the authorization (releasing the hold) without ever transferring money, which is a much cleaner customer experience than processing a refund. The capture typically happens when the order enters the "processing" state and inventory is confirmed as committed.
Q: How do you handle the scenario where a user's payment succeeds but the order creation fails due to a database error?
A: This is the "saga with compensation" pattern. The payment has already been authorized but not captured. The checkout orchestrator detects the order creation failure and triggers a compensation action: it voids the payment authorization, releases the inventory reservation, and returns an error to the user explaining that the order could not be placed. The user can retry. The voided authorization releases the hold on the customer's card within 3-5 business days (this is a bank-side process, not something we control). If the compensation itself fails (e.g., the void call to Stripe also fails), the payment reconciliation worker will detect the orphaned authorization during its periodic sweep and attempt to void it automatically. In the worst case, the authorization expires after 7 days without capture, and the funds are automatically released by the bank.
Q: How would you implement cart abandonment tracking for users who never log in (anonymous users)?
A: Anonymous cart abandonment tracking is challenging because we do not have the user's email address. Our approach uses multiple signals: (1) If the user has provided an email address during checkout (even if they did not complete it), we can send a recovery email. This is the most valuable recovery channel. (2) For users who never provided an email, we use browser push notifications (if the user has opted in) with a deep link back to their cart. (3) We use retargeting pixels (Facebook, Google) to serve display ads showing the items they left in their cart. (4) On subsequent visits (identified by the same session cookie), we display a "Welcome back! Your cart is waiting" banner with the exact items. The key insight is that anonymous abandonment recovery has much lower ROI than authenticated recovery, so we invest less in the infrastructure and more in the anonymous-to-authenticated conversion (e.g., "Log in to save your cart for later").
Advanced Topics
Q: How would you handle internationalization for the checkout flow, including multi-currency pricing, country-specific tax rules, and localized payment methods?
A: International checkout requires several layers of localization. (1) Currency: Prices are stored in the store's base currency, but displayed in the customer's local currency using real-time exchange rates (updated every 15 minutes). The final charge is in the store's base currency, and Stripe handles the conversion. (2) Tax: Tax calculation varies dramatically by jurisdiction. US states have different sales tax rules, EU has VAT, Canada has GST/PST/HST, and some countries have no sales tax at all. We delegate to Avalara or TaxJar for accurate tax calculation based on the shipping address. (3) Payment methods: Different countries prefer different payment methods. We support credit cards globally, but also add local methods: iDEAL (Netherlands), Bancontact (Belgium), SEPA (EU), Alipay (China), UPI (India), and BNPL services (Klarna, Afterpay) in supported regions. (4) Address format: Address formats vary by country. We use a dynamic address form that changes fields based on the selected country (e.g., UK addresses have a "postcode" field, Japanese addresses have different ordering). (5) Legal compliance: Different regions have different consumer protection laws (EU right to withdraw, GDPR consent requirements). The checkout flow must adapt to show the required legal disclosures based on the customer's location.
Q: Describe the failure modes of the checkout system and how you would handle each one.
A: The checkout system has several critical failure modes, each with a specific mitigation strategy: (1) Redis failure: Cart reads fall back to PostgreSQL (with slightly higher latency). Cart writes are buffered in a local queue and flushed when Redis recovers. Checkout sessions stored in Redis are reconstructed from the checkout database. (2) Payment gateway failure (Stripe down): Circuit breaker trips after 5 failures in 10 seconds. Users see "Payment service temporarily unavailable, please try again in a few minutes." No orders are lost because payment was not authorized. (3) Inventory service failure: The checkout orchestrator uses a cached inventory count (30-second TTL) and proceeds with a warning that "stock levels may not be up to date." If the reservation fails, the order is placed on a retry queue. (4) Tax service failure: Fall back to cached tax rates for the shipping jurisdiction. If no cache exists, show a warning that "Tax will be calculated after order placement" and use the last known rate for the jurisdiction. (5) Database failure: Order creation is retried on a different database replica. If all replicas are down, the order is written to a local file and replayed when the database recovers. (6) Network partition between services: Each service degrades gracefully using cached data. The checkout orchestrator returns a partial response with a warning flag, and the client can retry or proceed with reduced functionality.
Q: How would you design the system to handle "buy now, pay later" (BNPL) options like Klarna or Afterpay?
A: BNPL integration adds complexity because the payment is not captured immediately — the BNPL provider pays the merchant upfront but collects from the customer in installments. Our system handles BNPL as a separate payment method type with these differences: (1) The payment authorization goes through the BNPL provider's API instead of Stripe. (2) The capture happens immediately (not deferred) because the BNPL provider guarantees payment. (3) Refunds must be processed through the BNPL provider, not Stripe. (4) The order state machine has a "BNPL Active" sub-state that tracks whether the customer is still making installment payments. (5) If a customer disputes a BNPL charge, the dispute is routed to the BNPL provider's dispute system, not our payment team. We abstract these differences behind an IPaymentProvider interface so the checkout orchestrator does not need to know whether the customer paid with a credit card, Apple Pay, or Klarna.