E-commerce Architecture Deep Dive
The thesis: correctness lives at a few guarded decision points (inventory reservation, checkout idempotency, order-state transitions, outbox publication), and everything around them can be cached, eventually consistent or retried. This is a layer-by-layer breakdown of every mechanism a production e-commerce platform needs -- the product/SKU/offer model, the catalog read path, pricing as its own architectural concern, the search read model, cart storage, the inventory model and its reservation lifecycle, concurrency control and expiry, checkout idempotency and orchestration, the order data model and state machine, the transactional outbox, consumer idempotency, the fulfillment workflow, reconciliation, cancellation/returns/refunds, flash-sale hot-SKU contention, partitioning and observability.
Product, SKU & Offer Model
Treating 'the product' as the single unit that inventory and price attach to is a common early modeling mistake -- it breaks the moment a product has variants.
A Product carries stable, descriptive identity: title, description, brand, category, attributes, media references. It changes rarely. A SKU (or variant) is the actual purchasable unit -- a T-shirt in black, size M is a different SKU from the same T-shirt in blue, size L. Inventory and price attach to the SKU, not to the abstract Product.
| Entity | Key fields | Owns |
|---|---|---|
| Product | product_id, title, description, brand, category, attributes, media refs | Descriptive identity |
| SKU / Variant | sku_id, product_id, variant attributes, status | The purchasable unit -- attachment point for inventory and price |
| Offer / Price | sku_id, seller/merchant, currency, base/sale price, validity interval, price version | Commercial terms -- see Pricing Architecture below |
Read/write ownership
The catalog admin/CMS write path publishes Product and SKU metadata, which changes rarely. A separate pricing/merchant path commonly owns Offer data, which can change far more often. Inventory owns per-SKU (and per-location, once multi-location is in scope) stock counts. Keeping these write paths separate keeps unrelated update rates -- rare product edits vs. frequent price or inventory changes -- from contending with each other or forcing one cache-invalidation policy to fit all three.
Catalog Read Path
The catalog read path is cache-aside plus a CDN -- simple, but each piece has a correctness caveat worth being precise about.
Reads go through Catalog Service -> Cache -> Catalog DB on miss. Product images and static assets are served from a CDN independent of the cache/DB path. A write to the catalog (admin edit, price change, new product) invalidates the corresponding cache entry explicitly where correctness matters, rather than relying only on TTL expiry to eventually reflect the update.
Cache hit rate is workload-dependent
A high cache hit rate is common for popular catalogs with a long-tail access pattern, but the actual number depends on catalog size, popularity skew, cache capacity and eviction policy, TTL, and personalization. Treat any specific percentage as an illustrative example for this module, not a guaranteed baseline for every catalog.
Versioned media, not assumed-immutable media
Product images are not universally immutable. When media can change -- a corrected photo, a reshoot -- use a content-versioned URL (e.g. a path or query parameter that changes with the asset) or explicit cache/CDN invalidation, rather than assuming a permanent URL is always safe to cache forever.
CDN reduces origin load, it does not eliminate it
CDN edge caching substantially reduces repeated origin traffic for cacheable assets. It does not reduce origin load to literally zero -- cache misses, purges, geographic cold spots, and long-tail/rarely-requested assets still reach the origin.
Pricing Architecture
The single largest content gap in a naive e-commerce design: catalog metadata and checkout price get treated as one value, when they need to be modeled, owned, and versioned separately.
Display price is a cached read used for browsing and discovery -- fine to be a few seconds or minutes stale. A checkout quote is a separate, authoritative pricing decision, validated at the moment of checkout against current commercial terms: currency, active promotions, tax and shipping if in scope, and an explicit price version.
| Concept | What it answers | Where it lives |
|---|---|---|
| Display price | What does the product page show right now? | Catalog cache / read model |
| Checkout quote | What will this customer actually be charged, right now? | A versioned pricing/checkout validation step |
| Order line price | What did this customer agree to and pay? | Snapshotted onto the order line, immutable |
The checkout quote
A checkout quote can carry: quote_id/version, line prices, currency, discounts, tax/shipping if in scope, and an expiry. Promotions and coupons are optional inputs to the quote -- this module does not attempt to teach a full promotion-rule engine.
Order line price snapshot
Once an order is accepted, the accepted commercial terms -- unit price, currency, discounts applied -- are snapshotted onto the order line item. Never reconstruct historical order totals from today's catalog price; the catalog price today can legitimately differ from what the customer actually paid.
If price changes between cart and checkout, surface the change and require acceptance according to product policy -- never silently charge an arbitrary stale or new value. See Failure & Scale -> Price Changes During Checkout.
Search Read Model
The search index is a derived read model built from catalog change events -- not a second copy of the database, and not the checkout authority.
When a product or SKU is created or updated, an event triggers the indexing pipeline, which updates the search index. The catalog database remains authoritative; the index exists for relevance, faceting, typo tolerance and independent query scaling.
Indexing lag as an SLO, not a blanket promise
Rather than promising new products appear in search 'within minutes' universally, define and monitor an indexing-lag SLO appropriate to the product (for example, an illustrative target such as 'P95 index lag under N seconds'), and alert when it's breached. See Failure & Scale -> Search Indexing Backlog.
Relational databases are not incapable of full-text search
A relational database can provide basic full-text search adequate at small catalog scale. A dedicated search engine becomes valuable as scale and requirements grow -- relevance ranking, faceted filtering, typo tolerance, autocomplete, and independent scaling away from transactional write load.
Search results are discovery data, not checkout authority
Whatever price or availability a search result displays, checkout always revalidates against the authoritative pricing and inventory path -- regardless of how fresh or stale the search index happens to be at that moment.
Cart Storage
A cart's data model is small, but the fields it carries -- and which of them are authoritative -- matter more than the storage technology underneath it.
A cart is keyed by user ID (or a guest/session ID for unauthenticated shoppers) and holds a list of items: sku_id, quantity, and commonly a displayed-price snapshot for UX continuity, plus timestamps for expiry. A fast key-value store is a reasonable fit given the access pattern -- frequent small reads and writes, natural expiration -- but the model matters more than the specific store.
Guest vs. authenticated cart, and merge semantics
Guest carts are keyed by a session/device identifier. When a guest logs in, the guest cart and any existing authenticated cart need an explicit merge policy (merge quantities, prefer one side, prompt the user) -- this is a product decision, not a technical default.
Displayed price is a snapshot, never the authority
The cart may retain a displayed-price snapshot for UX and audit continuity, but it is never authoritative for the final charge. Checkout re-resolves the current SKU, price and availability regardless of what the cart last displayed. See Pricing Architecture above and Trade-offs & Decisions -> Price at Cart vs. Checkout.
TTL is a product decision
Cart retention -- days to weeks for B2C abandoned-cart recovery, potentially indefinite for B2B -- is a product decision the architecture accommodates, not a fixed technical constraint.
Inventory Model
Inventory is the correctness-critical layer of the platform -- but the model needs three states, not one counter, and it is a per-SKU record, not necessarily a single global number.
| State | Meaning |
|---|---|
| on_hand | Physically available stock recorded in the system |
| reserved | Held against active checkouts/orders, not yet committed |
| available_to_sell | on_hand - reserved (minus safety stock, if modeled) |
Real inventory models commonly extend this baseline with safety stock, damaged/unsellable stock, in-transit stock, and per-location allocation once multiple warehouses or stores are in scope (see Complete Design's brief mention -- this module does not attempt a full warehouse-management design).
Oversell prevention is a policy, not universal e-commerce law
'Never oversell' is the baseline invariant this module teaches, but some businesses intentionally allow backorders or preorders. The actual invariant that generalizes is: successful reservations must not allocate more sellable units than the inventory policy for that SKU permits -- which may or may not be zero backorder tolerance.
Inventory Reservation
A reservation needs a durable identity -- not just an atomic decrement -- so it can be found again, retried safely, committed, released or expired.
A reservation record carries: reservation_id, checkout/order ID, sku_id, quantity, state, expires_at, and a version/idempotency key. The reserve operation and the reservation record should be coordinated transactionally where the underlying store supports it.
UPDATE inventory
SET reserved = reserved + :qty,
version = version + 1
WHERE sku_id = :sku_id
AND (on_hand - reserved) >= :qty
AND version = :expected_version;
-- In the same transaction: insert the reservation row
INSERT INTO reservations (reservation_id, checkout_id, sku_id, qty, state, expires_at)
VALUES (:reservation_id, :checkout_id, :sku_id, :qty, 'ACTIVE', :expires_at)
ON CONFLICT (reservation_id) DO NOTHING;The reserve operation must be idempotent
A retried reserve request carrying the same checkout/reservation identity must not create a second reservation -- it should recognize the existing ACTIVE reservation and return it, not double-reserve the SKU.
Lifecycle
ACTIVE transitions to COMMITTED once the order reaches the point where the business considers the sale final, or to RELEASED/EXPIRED on definitive cancellation, definitive payment failure, or timeout.
Commit and release must each be idempotent too
Not only the initial reserve -- committing an ACTIVE reservation (on order confirmation) and releasing one (on definitive failure, cancellation, or expiry) are each separate operations that can be retried or redelivered. A retried commit against an already-COMMITTED reservation, or a retried release against an already-RELEASED one, must resolve to a safe no-op rather than erroring out or double-applying the transition.
Inventory Concurrency Control
There is more than one valid way to implement the same atomic check-then-reserve invariant -- the right choice depends on contention level and store.
| Mechanism | How it works | Good fit for |
|---|---|---|
| Conditional atomic update | UPDATE ... WHERE available >= qty in one statement | Ordinary contention -- the default baseline for most SKUs |
| Optimistic versioning | Read version, write with WHERE version = expected, retry on conflict | Moderate contention where retries are cheap and rare |
| Row lock | SELECT ... FOR UPDATE, then check-and-write inside the transaction | Cases needing multiple related checks/writes in one transaction |
| Serialized per-SKU processing | Funnel all reservation attempts for one SKU through a single ordered path | Extreme contention on one hot SKU -- see Flash-Sale Hot-SKU below |
-- One transaction (PostgreSQL syntax). reservation_id is the idempotency identity.
INSERT INTO reservations (reservation_id, sku_id, qty, state, expires_at)
VALUES (:reservation_id, :sku_id, :qty, 'ACTIVE', :expires_at)
ON CONFLICT (reservation_id) DO NOTHING; -- a retry inserts 0 rows: stop here
UPDATE inventory
SET reserved = reserved + :qty
WHERE sku_id = :sku_id
AND on_hand - reserved >= :qty; -- available_to_sell guard
-- 0 rows updated => ROLLBACK and report sold out; 1 row => COMMITReservation Expiry
Expiry has to be durable and recoverable -- an abandoned checkout must reliably release its reservation even if nothing else about the system reminds it to.
| Mechanism | How it works |
|---|---|
| Scheduled expiry scan | A periodic job scans ACTIVE reservations past expires_at and releases them |
| Delayed message | A release message is scheduled at reservation time via a delay queue or task scheduler and fires later |
| Expiry index | An index on expires_at makes the scan above efficient at scale |
| Workflow timer | If a durable workflow engine backs checkout, its own timer mechanism fires the release step |
TTL is a backstop signal, not the correctness mechanism
A cache TTL or expiry notification can opportunistically trigger cleanup, but correctness cannot depend solely on an unreliable expiry signal. The reservation record's own durable expires_at field, combined with one of the recovery mechanisms above, is what actually guarantees release.
Checkout Idempotency
A stable checkout idempotency identity is what maps repeated Place Order submissions to one logical checkout/order instead of creating duplicates.
A client-generated idempotency key (or a server-issued checkout session ID handed to the client at cart-to-checkout transition) maps to a durable uniqueness constraint. Store a request fingerprint -- cart contents, quote version -- alongside the key so a conflicting reuse of the same key (different cart contents under the same key) can be detected and rejected rather than silently applied.
Checkout Orchestration
Checkout is a saga -- a sequence of local commits across several services, coordinated by durable workflow state rather than one shared ACID transaction.
A useful baseline state machine: CHECKOUT_CREATED -> INVENTORY_RESERVED -> PAYMENT_PENDING -> CONFIRMED, with explicit failure and unknown branches (INVENTORY_UNAVAILABLE, PAYMENT_FAILED, PAYMENT_PENDING_UNKNOWN) rather than only a happy-path chain.
Durable workflow state
Each transition is recorded in durable storage before the next step is triggered, so an orchestrator crash mid-checkout can resume from the last recorded state instead of depending on one in-memory thread remembering what happened.
A workflow engine is optional, not required
A dedicated durable workflow engine is one valid implementation. A well-modeled state machine inside the checkout/order table, with guarded transitions and a resumable step, works too -- the requirement is durability and recoverability, not a specific product.
Checkout Compensation
Compensation is a fallible business operation performed after the fact -- not an automatic rollback the saga provides for free.
Typical compensating actions: release the inventory reservation, refund or void the payment, cancel the order. Each is a real operation against a real system, with its own possible failure.
Compensation can fail too
Example: payment definitively fails, and the compensating inventory-release call itself fails (network error, inventory service unavailable). The system retries the compensation idempotently, and reservation expiry plus reconciliation act as the backstop -- the design never assumes compensation succeeded merely because it was invoked.
Order Data Model
An order is a header plus an immutable, snapshotted set of line items -- it must never need to reconstruct itself from mutable catalog or pricing data.
| Order header | Line item (snapshotted) |
|---|---|
| order_id, checkout_id, customer_id, status, currency, totals, created_at | sku_id, product title/description snapshot as needed, unit_price, currency, quantity, discount/tax/shipping allocation if in scope |
Order State Machine
Order state, payment state and fulfillment state are three separate state machines -- collapsing them into one enum produces invalid combinations.
| Domain | Example states |
|---|---|
| Order | CREATED, PAYMENT_PENDING, CONFIRMED, CANCELLED, COMPLETED |
| Payment (owned by Payment System) | pending, unknown, succeeded, failed, refunded |
| Fulfillment | UNALLOCATED, ALLOCATED, PICKING, PACKED, SHIPPED, DELIVERED, CANCELLED |
Valid combinations that a single boolean can't express
An order can be CONFIRMED while fulfillment has not started; partially shipped while other lines are still picking; or paid while a cancellation/refund is already in progress. A single order_complete boolean cannot represent any of these -- three separate, coordinated state machines can.
Transactional Outbox
Committing the order's CONFIRMED state to the database and publishing order.confirmed to a broker are two separate operations against two separate systems -- and that gap silently drops or duplicates events if left unaddressed.
Failure window: the order DB commits CONFIRMED, the process crashes before the publish call, and no fulfillment event is ever produced. The fix: in the same database transaction that writes the order transition, also insert a durable outbox row representing the event. A separate relay reads unpublished outbox rows and publishes them to the broker, marking them published once acknowledged.
Because the relay can itself crash between publishing and marking a row published, it may republish the same event on restart -- the outbox closes the durability gap, it does not eliminate the need for idempotent consumers (see Event Consumer Idempotency below).
Event Consumer Idempotency
At-least-once delivery is the guarantee actually being provided -- every downstream consumer of order events needs to tolerate duplicates by design.
Fulfillment consumer
A stable business idempotency key, conceptually UNIQUE(order_id, fulfillment_operation), ensures a duplicate order.confirmed event does not create a duplicate shipment.
Notification consumer
Deduplicate on a stable message/business identity where sending a duplicate confirmation email or push notification matters to the product.
Analytics consumer
Duplicate events distort aggregate metrics if not deduplicated, or the aggregation itself needs to be designed to tolerate duplicates explicitly (e.g. counting distinct order IDs rather than raw event counts).
Fulfillment Workflow
Fulfillment is a real stateful workflow with its own transitions -- not a generic async consumer that just marks the order 'shipped.'
A fulfillment request is created off the order.confirmed event, then moves through allocation (assigning a location/warehouse to fulfill from), pick, pack, shipment, and delivery-status tracking. An order can ship in more than one package -- partial shipment is a normal, not exceptional, path.
Order Reconciliation
A distributed checkout can accumulate inconsistencies even when every individual service retries correctly -- reconciliation is the sweep that catches what retries alone didn't resolve.
| Discrepancy | What it looks like |
|---|---|
| Paid but unconfirmed | Payment succeeded; order still PAYMENT_PENDING |
| Confirmed without reservation | Order CONFIRMED; no COMMITTED inventory reservation backing it |
| Expired reservation, active checkout | Reservation released by TTL while the checkout believed it still held stock |
| Duplicate fulfillment | More than one shipment created for the same order line |
| Refund expected, not completed | Order CANCELLED; payment refund still pending or missing |
| Stuck outbox event | An outbox row written but never marked published past a normal relay lag window |
Reconciliation properties
Idempotent (a re-run of the same batch is a no-op for already-repaired cases), observable (every discrepancy and repair is logged), bounded/batched (never one giant unindexed scan), safe to rerun, and able to quarantine cases that need manual/human review rather than auto-repairing every discrepancy -- financial and physical ambiguities in particular often need explicit review policy rather than automatic correction.
Cancellation, Returns & Refunds
Cancellation and return are their own distributed workflows -- not a simple state flag -- and refund is a separate financial operation owned by the Payment System.
Cancellation before fulfillment starts: validate the order is still in an eligible state, cancel the fulfillment request if it hasn't started, release or restore inventory according to the inventory model's semantics, and void or refund payment as required. After shipment, cancellation typically becomes a return/refund workflow instead.
Returns
Return authorization and receipt are tracked at a high level. Returned goods often need inspection before becoming sellable again -- they do not necessarily restock immediately on receipt.
Refunds are independent of order cancellation
A refund is owned by the Payment System and can itself be pending, failed, or ambiguous -- see the Payment System module for its full lifecycle. Order = CANCELLED and refund = SUCCEEDED are two separate facts in two separate state machines; never equate them, and never report a cancellation as financially complete until the refund state actually resolves.
Flash-Sale Hot-SKU Contention
Horizontal scaling of a stateless Inventory Service does not remove contention on one scarce SKU -- the bottleneck is a single row or partition, not compute capacity.
Browse traffic during a flash sale can scale through CDN and cache. Checkout admission for the scarce SKU cannot -- every reservation attempt for that one SKU ultimately contends on the same row or partition, regardless of how many stateless service instances are running.
Aggressive client retries make contention worse
Retrying every failed contender immediately amplifies load on the exact hot row that's already contended -- bounded retry with backoff is required, not unconditional immediate retry.
Waiting room / virtual queue
Smooths admission into the checkout path when business requirements justify it (extreme scarcity, ticketing-like demand). It is an optional pattern, not a requirement for ordinary commerce.
Per-SKU serialized reservation processing
An alternative for extreme contention: funnel every reservation attempt for one hot SKU through a single ordered processing path, trading some latency for the elimination of contention-driven retries.
Partitioning distributes SKUs, not one SKU
Partitioning by SKU spreads different SKUs across shards, but it cannot split one indivisible hot SKU without additional design -- sub-partitioning the available count, or a reservation-counter/preallocation scheme, can go further, at the cost of its own added reconciliation complexity.
Fail fast once sold out
'Sold out' should stop unnecessary downstream payment traffic quickly, rather than let doomed checkout attempts reach the payment provider after the SKU is already exhausted.
Partitioning
Catalog, cart, inventory and orders don't need to share one partition key -- each workload's access pattern picks a different natural key.
| Domain | Common partition key | Why |
|---|---|---|
| Catalog | product/SKU ID (or category) | Read-heavy, easy to fan out |
| Cart | user/session ID | Naturally scoped to one shopper |
| Inventory | SKU ID (plus location, if multi-location) | Isolates contention per SKU |
| Orders | order ID, with customer/merchant secondary indexes | Write-heavy, but also needs customer- and merchant-facing lookups |
Observability
Metrics should be separated by workload boundary the same way the architecture is -- a search indexing lag spike and an inventory reservation conflict spike are different incidents, even though both can look like 'the site is slow' to a customer.
| Metric | Why it matters |
|---|---|
| Browse / search latency | Read-path health, separate from checkout-path health |
| Cache hit/miss rate | Catalog cache effectiveness and origin-load pressure |
| Search indexing lag | Discovery freshness, tracked against its own SLO |
| Add-to-cart / checkout conversion | Funnel health, a product signal as much as a technical one |
| Inventory reservation conflict/failure rate | Contention level on scarce SKUs |
| Reservation age / expiry rate | Abandoned-checkout volume and reservation-hold pressure |
| Checkout workflow age | Stuck or slow-moving sagas |
| Payment pending/unknown count | Ambiguous payment volume awaiting resolution |
| Order confirmation lag | Time from payment resolution to order CONFIRMED |
| Outbox lag | Time between a DB commit and the event actually reaching the broker |
| Consumer lag | How far behind fulfillment/notification/analytics consumers are |
| Fulfillment backlog | Warehouse-side processing capacity vs. incoming confirmed orders |
| Reconciliation mismatches | Leading indicator of a systemic integration issue, not just isolated incidents |
| Oversell / backorder incidents | Direct measure of the platform's core correctness invariant |
| Cancellation/refund lag | Time from cancellation request to refund resolution |