All lessons
E-commerceIntermediate16 min readIn my stack

Catalogue, cart and checkout

The backbone of every online shop: products, prices, carts and orders that never lie.

  • Products & variants
  • Prices
  • Guest cart
  • Checkout
  • Orders
Step 1/5
The customer checks out. The shop creates the order with status “pending”.

In 30 seconds

An online shop is three systems glued together: a catalogue that describes what you sell, a cart that remembers what the customer wants, and a checkout that turns that wish into a paid, trackable order. Getting the data model right early — variants, money as integers, an explicit order state machine — saves months of painful fixes later.

Why I use it

  • I use this structure because I have built it again and again: restaurant ordering, pet supplies, beauty products, wine, merch, food and tours all reduce to the same catalogue → cart → order pipeline.
  • Most production bugs in shops are not exotic: a rounding error in a price, a cart that loses items after login, an order paid twice, a product sold that is no longer in stock. A clean model removes whole classes of them.
  • The checkout is where the business earns money. Every extra step or confusing error costs real orders, so it deserves senior-level care.
  • Once the core is solid, promotions, loyalty, ERP sync and apps can be added on top without rewriting it.

How it works

01The catalogue: products, variants and attributes

Think of a product as a shelf label ("Nail polish") and a variant as the actual thing you put in a box ("Nail polish, red, 15 ml"). Customers browse products, but you sell, stock and price variants — each variant has its own SKU (stock-keeping unit, a unique code), price and stock. Attributes describe variants: colour, size, weight, flavour. Grouping attributes into attribute families ("clothing" has size and colour, "pet food" has weight and flavour) keeps the admin sane. Categories form a tree (Dogs → Food → Dry food); store them with a nested-set or a parent_id plus a cached path so "all products under Dogs" is one query. Brands, tags and collections are separate many-to-many links, not more categories.

  • Product: name, description, images, SEO fields, category links.
  • Variant: SKU, attribute values, price, weight, barcode, stock link.
  • Some goods are sold by pack or by weight — model the sale unit explicitly instead of hacking quantities.

02Money: integers, currencies and taxes

Never store money as a float. 0.1 + 0.2 is not 0.3 in binary floating point, and those tiny errors add up into real accounting differences. Store amounts as integers in the smallest unit (cents, or whole drams for currencies without minor units) together with an ISO 4217 currency code, and do arithmetic with a money library or value object. Decide once whether catalogue prices include tax (common for consumer shops in Europe, Armenia and Australia) or exclude it (common in the US), and calculate tax per line with a clear rounding rule. Save a snapshot of every price, discount and tax on the order line: the catalogue will change tomorrow, but the order must still show what the customer paid.

ts
// Money as a small value object: integer minor units + currency
type Money = { amount: number; currency: "USD" | "EUR" | "AMD" };
 
const add = (a: Money, b: Money): Money => {
  if (a.currency !== b.currency) throw new Error("Currency mismatch");
  return { amount: a.amount + b.amount, currency: a.currency };
};
 
// Tax included in the price: extract it with one explicit rounding rule
const taxIncluded = (gross: Money, ratePercent: number): Money => ({
  amount: Math.round((gross.amount * ratePercent) / (100 + ratePercent)),
  currency: gross.currency,
});
 
const line = { amount: 1999, currency: "USD" } as const; // $19.99
console.log(taxIncluded(line, 20)); // { amount: 333, currency: "USD" }

03The cart: guests, merging and validation

Let people shop without an account. A guest cart lives on the server, identified by a random token stored in a cookie (web) or secure storage (app). Keeping it server-side means prices and rules are calculated in one place, and the cart follows the user across devices once they log in. When a guest signs in, merge: add guest lines to the account cart, combine quantities for the same variant, then delete the guest cart. Treat the cart as a wish, not a promise. Every time it is read — and always at checkout — re-validate it: is the variant still active, is there enough stock, has the price changed, is the branch open, is the minimum order met? Return clear messages ("Price changed from 12.00 to 13.50") instead of silently fixing things.

  • Store variant IDs and quantities in the cart, never trusted prices from the client.
  • Recalculate totals on the server for every response.
  • Offer reorder: copy lines from a past order into the cart, then validate as usual.

04Checkout and the order state machine

Checkout collects what the order needs — contact, address or pickup point, delivery slot, payment method — then creates the order in one database transaction: copy cart lines with price snapshots, reserve stock, store totals, and set status to pending payment. From then on, the order moves through an explicit state machine. A state machine is simply a list of allowed statuses and allowed moves between them. Writing it down prevents nonsense like a cancelled order becoming delivered, and every move is logged in a status history table with who and when. Side effects — emails, push, kitchen screens, ERP export — hang off transitions, ideally through queued jobs.

ts
const transitions = {
  pending_payment: ["paid", "cancelled"],
  paid: ["preparing", "refunded"],
  preparing: ["ready", "cancelled"],
  ready: ["out_for_delivery", "picked_up"],
  out_for_delivery: ["delivered", "failed_delivery"],
  delivered: ["refunded"],
} as const;
 
type Status = keyof typeof transitions | "cancelled" | "refunded" | "picked_up" | "failed_delivery";
 
export function canMove(from: Status, to: Status): boolean {
  const allowed = (transitions as Record<string, readonly string[]>)[from] ?? [];
  return allowed.includes(to);
}

05Delivery, pickup and time slots

Fulfilment is where shops differ most. Delivery needs an address, a zone and a fee. Zones are usually polygons on a map: geocode the address to coordinates, then check which polygon contains the point to get the fee, minimum order and serving branch. Pickup (takeaway) needs a branch and maybe a product list that differs per branch. Time slots turn opening hours into choosable windows: ASAP, or scheduled in 15- or 30-minute steps, respecting preparation time, closing time and a "business day" that may end after midnight. Generate slots on the server, in the shop's time zone, and validate the chosen slot again at checkout — a slot that was free when the page loaded may be gone a minute later.

06Preventing overselling and sending the right emails

Overselling happens when two customers buy the last item at the same moment. The fix is to make the stock change atomic. Either lock the row (SELECT … FOR UPDATE inside a transaction) or use a conditional update that only succeeds if enough stock remains, and check the number of affected rows. Reserve stock when the order is created and release it if payment fails or times out; a scheduled job cancels abandoned pending orders. Transactional emails — order received, paid, shipped, refunded — should be sent from a queue after the transaction commits, so a slow mail server never blocks checkout and no email is sent for an order that was rolled back.

sql
-- Atomic decrement: succeeds only when enough stock is left
UPDATE stock_levels
SET quantity = quantity - 2
WHERE variant_id = 42
  AND warehouse_id = 1
  AND quantity >= 2;
-- 1 row affected → reserved; 0 rows → out of stock, tell the customer

Use it when

  • You are building a shop with real business rules (variants, delivery zones, promotions, ERP) that off-the-shelf platforms fight against.
  • The same catalogue must serve a website, a mobile app and an admin from one API.
  • Orders must integrate with accounting, fiscal receipts, kitchens or warehouses.
  • You need full control over checkout UX and data ownership.

Skip it when

  • A small shop with standard products and no integrations — a hosted platform like Shopify is faster and cheaper.
  • You have no team to maintain payments, security updates and integrations after launch.
  • The business model is still unproven; validate it first with a hosted store.

Alternatives

  • Shopify

    Hosted, fast to launch, strong ecosystem; custom logic lives in apps and checkout extensions with limits.

  • WooCommerce

    WordPress plugin, cheap and flexible, but performance and code quality depend heavily on plugins.

  • Medusa / Saleor

    Open-source headless commerce engines — a good middle ground when you want code control without starting from zero.

  • commercetools

    Enterprise headless commerce APIs; powerful, but priced and staffed for large companies.

Best practices

  1. Store money as integers plus a currency code; never floats.
  2. Snapshot prices, discounts, taxes and product names onto order lines.
  3. Recalculate every total on the server; the client only sends IDs and quantities.
  4. Model order statuses as an explicit state machine with a history table.
  5. Make stock changes atomic and release reservations on failed or expired payments.
  6. Send emails and integrations from queued jobs after the transaction commits.
  7. Keep a guest cart on the server and merge it on login.

Common mistakes

  • Using float columns for prices and discovering cent differences in reports.
  • Reading prices from the order's current product instead of a snapshot, so old invoices change.
  • Trusting a total sent by the browser or app.
  • Changing order status with free-form updates, leading to impossible states.
  • Checking stock only when adding to the cart, not again at checkout.

Deep dive

07Idempotent order creation: surviving double clicks and retries

A customer taps "Place order" twice, a mobile app retries after a timeout, a load balancer replays a request. Without protection each of these can create a second order and reserve stock twice. The fix is a checkout key: the client generates a random UUID when the checkout screen opens and sends it with the submit request. The server stores it on the order under a unique constraint. The first request creates the order; any repeat with the same key returns the existing order instead of creating a new one. Keep the key for the life of that checkout attempt and generate a fresh one only when the cart changes. Pair it with a short per-cart lock (a Redis lock or an advisory lock) so two parallel requests do not both pass validation before either inserts.

sql
-- One order per checkout attempt, enforced by the database
ALTER TABLE orders ADD COLUMN checkout_key UUID;
CREATE UNIQUE INDEX orders_checkout_key_uq ON orders (checkout_key);
 
-- Inside the checkout transaction
INSERT INTO orders (checkout_key, customer_id, status, total_amount, currency)
VALUES ($1, $2, 'pending_payment', $3, $4)
ON CONFLICT (checkout_key) DO NOTHING
RETURNING id;
-- No row returned → a previous request already created it:
-- SELECT id FROM orders WHERE checkout_key = $1 and return that order.

08Allocating discounts and rounding without losing a cent

A cart-level discount ("5.00 off the order") must be split across lines, because tax, refunds and ERP exports work per line. Split it proportionally to each line's value, round each share down, then give the leftover cents to the lines with the largest remainders. The shares always add up to the exact discount, and a later partial refund of one line returns precisely what that line was charged. Also respect the currency's minor unit from ISO 4217: most currencies have 2 decimals, the Japanese yen has 0, and the Kuwaiti dinar has 3. Never hard-code "divide by 100". For multi-currency stores, snapshot the exchange rate on the order, and decide whether you convert prices (display currency) or actually charge in that currency (settlement currency).

ts
// Split an integer amount across weights; parts always sum to the total
export function allocate(total: number, weights: number[]): number[] {
  const sum = weights.reduce((a, b) => a + b, 0);
  const raw = weights.map((w) => (total * w) / sum);
  const parts = raw.map(Math.floor);
  let rest = total - parts.reduce((a, b) => a + b, 0);
  const order = raw
    .map((r, i) => ({ i, frac: r - Math.floor(r) }))
    .sort((a, b) => b.frac - a.frac);
  for (const { i } of order) {
    if (rest-- <= 0) break;
    parts[i] += 1;
  }
  return parts;
}
 
allocate(500, [1999, 999, 2]); // [333, 167, 0] → sums to 500

09Read models for fast catalogue pages

A category page that joins products, variants, prices, stock, images and promotions on every request becomes slow as the catalogue grows. The usual answer is a read model: a denormalised table (or search index document) with one row per product holding exactly what the listing needs — minimum price, in-stock flag, main image, attribute values for filters, rating. Background jobs update it when the source data changes, and the storefront reads only this table. Cache rendered fragments or API responses with tags so a price change invalidates just the affected products. Keep the write model (normalised tables, transactions) as the source of truth; the read model can always be rebuilt from it. This split, sometimes called CQRS (command query responsibility segregation), lets reads scale without complicating checkout.

  • Rebuild a product's read row in a queued job after price, stock or content changes.
  • Paginate listings with keyset pagination (WHERE id > last_id) for deep pages.
  • Never compute live promotion prices for every product on a listing; precompute badges.

10Security edge cases in carts and orders

Carts and orders are classic targets for insecure direct object references (IDOR): changing /orders/1042 to /orders/1043 and seeing someone else's order. Use unguessable public identifiers (UUIDs or random tokens) in URLs and always scope queries by the owner. Rotate the guest cart token when the user logs in, so a token captured before login cannot be used to read the account cart. Validate quantities as positive integers with an upper limit — negative quantities have produced real refunds in poorly built shops. Rate-limit coupon and checkout endpoints to slow down brute force and card testing, where attackers run stolen cards through small orders. Finally, keep personal data in orders minimal and plan how long you retain it, because order tables quietly become your largest store of personal data.

Real-world scenarios

01

A flash sale oversells the hero product

Problem

During a one-hour sale, 300 units were sold while only 200 existed. Stock was checked when the item was added to the cart and again in application code just before the order was saved.

How to solve it

The check and the decrement were two separate steps, so parallel checkouts all saw stock available. Replace them with one conditional update — UPDATE stock_levels SET quantity = quantity - :qty WHERE variant_id = :id AND quantity >= :qty — inside the order transaction and treat zero affected rows as sold out. Reserve at order creation, release on payment failure, and expire unpaid orders after 15 minutes with a scheduled job. For the next sale, add a Redis counter as a fast pre-check to shed load, keep the database guard as the authority, and run a load test with concurrent checkouts on staging before the campaign.

02

Customers see a higher total at checkout than in the cart

Problem

Support receives complaints that the cart shows 42.00 but the payment screen asks for 45.50. Nothing is actually broken in either screen on its own.

How to solve it

This almost always means two code paths calculate totals: the cart page uses a simplified calculation while checkout applies delivery fees, promotion rules or tax differently. Move all pricing into one server-side pricing service that returns a full breakdown — lines, discounts, delivery, tax, total — and make both screens render that response. When something legitimately changes between screens (delivery fee after the address is entered, a price update), return an explicit message describing the change. Add a regression test that runs the same cart through the cart endpoint and the checkout endpoint and asserts identical totals.

03

Delivery slots overflow at dinner time

Problem

A restaurant branch can prepare about 20 orders per 15 minutes, but the 19:00 slot regularly receives 60 orders. The kitchen falls behind and late deliveries trigger refunds.

How to solve it

Slots need capacity, not just times. Store a capacity per branch and slot (configurable per weekday), and treat slot booking like stock: an atomic conditional increment of booked_count that only succeeds while it is below capacity, done in the same transaction as order creation. Render full slots as disabled and suggest the nearest free one. ASAP orders should consume capacity from the first slot after preparation time. Give branch managers a switch to pause ASAP orders or reduce capacity during a rush, and report slot utilisation so the business can staff peak hours.

Cheat sheet

Atomic stock decrement
UPDATE stock_levels SET quantity = quantity - :qty WHERE variant_id = :id AND quantity >= :qty;
Lock a row inside a transaction
SELECT quantity FROM stock_levels WHERE variant_id = :id FOR UPDATE;
Idempotent order insert (PostgreSQL)
INSERT INTO orders (checkout_key, ...) VALUES (...) ON CONFLICT (checkout_key) DO NOTHING RETURNING id;
Money columns
amount BIGINT NOT NULL, currency CHAR(3) NOT NULL -- minor units + ISO 4217 code
Format money for display
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(1999 / 100) // $19.99
Guest cart cookie
Set-Cookie: cart_token=<random>; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000; Path=/
Find the delivery zone (PostGIS)
SELECT id FROM delivery_zones WHERE ST_Contains(area, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326));
Expire unpaid orders (Laravel)
Schedule::command('orders:expire-pending')->everyFiveMinutes()->withoutOverlapping();

In my real projects

  • On a restaurant-chain ordering platform I built guest carts with merge on login and reorder, delivery zones resolved from geocoordinates, and a time-slot engine for ASAP, scheduled delivery and takeaway with "business today" and interval rounding.
  • On a pet-supplies platform the catalogue has nested categories, brands, attribute families and variants, with pack vs weight sale modes and pricing modelled as value objects; checkout uses an order lifecycle service and can fulfil from a warehouse, a supplier or backorder.
  • I turned repeated account, cart and checkout code into reusable in-house packages used across about ten client sites, deliberately keeping raw card data out of the persisted cart store.

Questions & answers

Q1Why should money never be stored as a float?

Floats are binary approximations, so values like 0.1 cannot be represented exactly. Small errors appear in sums, discounts and tax splits, and after thousands of orders the books stop matching the payment provider. I store integers in the smallest currency unit with an ISO currency code, and use a money value object for arithmetic and formatting. Rounding happens in one place with an explicit rule, typically per line. For databases, an integer or a fixed DECIMAL column both work; what matters is never doing float arithmetic in application code.

Q2How do you merge a guest cart when the user logs in?

The guest cart is a server-side cart linked to a random token. On login, I load both carts in a transaction, move each guest line into the user's cart, sum quantities for the same variant, and cap them by stock or per-order limits. Then I delete the guest cart and clear the token. After merging I re-validate the whole cart and return any warnings, such as an item that went out of stock. Merging on the server keeps web and app consistent and avoids losing items.

Q3How do you prevent two customers from buying the last item?

Stock changes must be atomic. Inside a transaction I either lock the stock row with SELECT FOR UPDATE, or run a conditional update that only decrements when enough quantity remains and check the affected row count. The reservation happens when the order is created, not when an item is added to the cart. If payment fails or the order expires, a job releases the reservation. For very hot items, a Redis counter or a queue can serialise access, but the database guard stays as the final authority.

Q4Why snapshot prices on the order line?

Products change: prices go up, names are edited, products are deleted. An order is a legal and financial record of what happened at a specific moment, so it must not depend on the current catalogue. I copy the product name, variant attributes, unit price, discount, tax and currency onto each order line. Invoices, refunds, reports and ERP exports then read from the order itself. It also makes refunds simple, because you know exactly what was charged for each line.

Q5What does a good checkout flow look like technically?

The client sends the cart token, fulfilment choice, address or branch, time slot and payment method. The server re-validates everything: availability, stock, prices, promotions, delivery zone and slot. If valid, it creates the order and reserves stock in one transaction with status pending payment, then starts the payment. The order only becomes paid after a verified confirmation from the gateway. Emails, push and integrations are triggered by status transitions through queued jobs, and expired pending orders are cancelled by a scheduled job.

Q6Should prices include tax?

It depends on the market. Consumer shops in most of Europe, Armenia and Australia show tax-inclusive prices, and the tax is extracted for invoices. US shops usually show prices before sales tax, which is calculated from the delivery address. The key is to pick one convention per store, store the rate and tax amount on each order line, and apply one rounding rule. For complex multi-region tax, a tax service such as Stripe Tax or Avalara is often cheaper than maintaining rules yourself.

Senior-level questions

S1How would you make checkout safe against retries, double submits and parallel requests end to end?

I layer three protections. First, the client sends a checkout key generated per attempt; a unique index on orders means a repeated request returns the existing order instead of creating another. Second, a short lock per cart serialises parallel submits, so validation and insertion happen once. Third, every side effect is idempotent: stock reservation uses conditional updates, the payment call carries an idempotency key derived from the order, and emails or ERP exports run from queued jobs keyed by the order and transition. The trade-off is some extra state and a few more indexes, but it removes an entire class of incidents — duplicate orders and double charges — that are very expensive to clean up by hand.

S2When would you split catalogue, cart and orders into separate services?

Rarely at the start. A modular monolith with clear modules — catalogue, pricing, cart, orders, payments — and one database gives transactions across them, which checkout depends on. I would split when there is a concrete pressure: the catalogue read load needs to scale independently, different teams own different domains and block each other's releases, or a module has very different availability needs. Even then, I would first extract read-heavy parts, such as search and catalogue read models, which tolerate eventual consistency. Orders and payments stay together longest, because splitting them replaces a database transaction with a saga of compensating steps, more failure modes and harder debugging. Microservices solve organisational scaling more than technical scaling.

S3How do you scale a shop for a big sales day?

Reads and writes scale differently. Catalogue and listing traffic is read-heavy, so I serve it from read models, cache responses at the CDN with short TTLs and tag-based invalidation, and keep search on its own engine. Checkout is write-heavy and must stay correct, so I keep it lean: no heavy work in the request, side effects in queues, indexes checked on order and stock tables, and connection pooling sized for the peak. Hot products get a fast Redis pre-check in front of the database stock guard. I load-test the real checkout flow, set up alerts on queue depth and error rates, freeze deployments during the event, and prepare feature flags to switch off non-essential features such as recommendations if the system is under pressure.

Glossary

SKU
Stock-keeping unit: a unique code for one sellable variant, used for stock, pricing and integrations.
Variant
A concrete version of a product with specific attribute values, such as red, size M.
Minor unit
The smallest unit of a currency, such as cents. ISO 4217 defines how many decimals each currency has.
Price snapshot
A copy of name, price, discount and tax stored on the order line at purchase time.
Reservation
Stock set aside for a placed order so nobody else can buy it before shipping or cancellation.
Checkout key
A client-generated unique ID per checkout attempt that stops duplicate orders on retries.
Read model
A denormalised copy of data shaped for fast reading, rebuilt from the source tables.
IDOR
Insecure direct object reference: accessing someone else's record by changing an ID in a URL or request.
Card testing
Fraudsters running many small payments with stolen cards to see which ones still work.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.