CASE STUDY

E-commerce Tables and Indexes for Recent-Spend Queries

3 min read·417 words·Beginner

How to use this case study

SDE-2 / Mid

Design normalized tables (users, products, orders, order_items) that keep the price actually charged, with keys and constraints.

SDE-3 / Senior

Write the "total spend per user in the last 30 days" query, and choose indexes (composite, covering) so it's fast. Explain the query plan.

Staff / Principal

Discuss partitioning orders by date, summary tables for dashboards, refunds and currency, and when to denormalize.


0) Problem Restatement

Visa asked a database design question: design normalized tables for an e-commerce system (users, products, orders, order line items) that preserve the price the customer actually paid, even if the product's price changes later, and enforce useful constraints. Then write and optimize queries such as "total spend per user in the last 30 days" and "top 10 customers by spend this month".

Asked at: Visa — 1 candidate report between Aug 2026 and Aug 2026.

1) Tables

CREATE TABLE users (
  user_id     BIGINT PRIMARY KEY,
  email       TEXT NOT NULL UNIQUE,
  created_at  TIMESTAMP NOT NULL
);
CREATE TABLE products (
  product_id        BIGINT PRIMARY KEY,
  name              TEXT NOT NULL,
  current_price_cents INT NOT NULL CHECK (current_price_cents >= 0)   -- can change any time
);
CREATE TABLE orders (
  order_id     BIGINT PRIMARY KEY,
  user_id      BIGINT NOT NULL REFERENCES users(user_id),
  status       TEXT NOT NULL CHECK (status IN ('placed','paid','shipped','cancelled','refunded')),
  total_cents  INT NOT NULL CHECK (total_cents >= 0),   -- sum of items at purchase time
  currency     CHAR(3) NOT NULL,
  created_at   TIMESTAMP NOT NULL
);
CREATE TABLE order_items (
  order_id          BIGINT REFERENCES orders(order_id),
  line_no           INT,
  product_id        BIGINT NOT NULL REFERENCES products(product_id),
  quantity          INT NOT NULL CHECK (quantity > 0),
  unit_price_cents  INT NOT NULL CHECK (unit_price_cents >= 0),   -- price CHARGED, copied at purchase
  PRIMARY KEY (order_id, line_no)
);
Key point: order_items.unit_price_cents is a copy of the price at purchase time. We never compute past spend from products.current_price_cents, because that changes. (This is intentional denormalization for correctness of history.)

2) The Query

-- Total spend per user in the last 30 days (paid or shipped orders only)
SELECT o.user_id, SUM(o.total_cents) AS spend_cents
FROM orders o
WHERE o.created_at >= now() - INTERVAL '30 days'
  AND o.status IN ('paid', 'shipped')
GROUP BY o.user_id
ORDER BY spend_cents DESC
LIMIT 10;

-- One user's recent spend (e.g., for a profile page)
SELECT COALESCE(SUM(total_cents), 0) FROM orders
WHERE user_id = $1 AND created_at >= now() - INTERVAL '30 days' AND status IN ('paid','shipped');

3) Indexes

  • For one user's recent spend: a composite index (user_id, created_at). The DB jumps to that user and reads only the last 30 days. Make it covering by including status, total_cents (CREATE INDEX ... ON orders (user_id, created_at) INCLUDE (status, total_cents)), so it never touches the table.
  • For top spenders across all users in a time range: an index on (created_at) including (user_id, status, total_cents), so the scan reads only recent rows. For very large tables, partition orders by month so old partitions are skipped entirely (partition pruning).
  • Always check with EXPLAIN ANALYZE: we want an index or index-only scan, not a sequential scan of the whole orders table.

Architecture Diagram

flowchart LR
    Q["Query: user 42, last 30 days"] --> IX["Index (user_id, created_at) INCLUDE (status, total_cents)"]
    IX --> R["Read only matching index entries"]
    R --> SUM["SUM total_cents"]

4) Extras

  • Refunds: store refunds as separate rows (or negative adjustments) with their own dates, so spend = charges − refunds in the window.
  • Currency: store the order currency, and convert with the rate at purchase time for reporting in one currency.
  • Dashboards at scale: a daily summary table user_daily_spend(user_id, day, spend_cents), updated incrementally. Then 30-day spend = the sum of ≤ 30 small rows.
  • Why total_cents on orders? It's a small, safe denormalization that avoids joining order_items for spend queries. Keep it consistent by computing it in the same transaction as the items.


5) Wrap-Up

Use normalized users, products, orders and order_items tables with foreign keys and CHECK constraints, and copy the charged unit price into order_items (plus an order total) so history never changes when product prices do. Answer recent-spend queries from orders with a composite covering index on (user_id, created_at) (and a created_at index or monthly partitions for all-user rankings), verify with EXPLAIN, and add refund handling and daily summary tables as data grows.

More Case Studies

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with our AI interviewer.

Start System Design Interview →