E-commerce Product Catalog
Design the schema for a catalog that supports products with variants (size, color), nested categories, multi-currency pricing, and per-warehouse inventory. Read-heavy: product detail pages and category listings dominate traffic.
Clarifying questions
- Is the variant axis fixed (size, color) or arbitrary per product (size, color, material, voltage)?
- Do we need historical pricing for audit, or just current price per currency?
- Single warehouse or multi-warehouse with regional fulfillment?
- Are categories a strict tree or a DAG (Directed Acyclic Graph; a product belongs to multiple)?
- How tolerant are we of inventory race conditions — strict count or eventually consistent?
Core requirements
- Model products with arbitrary variant axes without schema changes per product type.
- Render a product detail page in one round trip (product, variants, prices, stock).
- Browse by category with filters on price, availability, and attributes.
- Track inventory per (variant, warehouse) with safe decrement semantics.
- Support multiple currencies with explicit conversion timing.
- Allow a single SKU to live in multiple categories.
Canonical schema (PostgreSQL)
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES categories(id),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
path LTREE NOT NULL
);
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
brand TEXT,
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE product_categories (
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (product_id, category_id)
);
CREATE TABLE variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
sku TEXT UNIQUE NOT NULL,
options JSONB NOT NULL,
weight_grams INTEGER,
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE prices (
variant_id UUID NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
currency CHAR(3) NOT NULL,
amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0),
effective_from TIMESTAMPTZ NOT NULL DEFAULT now(),
effective_to TIMESTAMPTZ,
PRIMARY KEY (variant_id, currency, effective_from)
);
CREATE TABLE inventory (
variant_id UUID NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
warehouse_id UUID NOT NULL,
on_hand INTEGER NOT NULL CHECK (on_hand >= 0),
reserved INTEGER NOT NULL DEFAULT 0 CHECK (reserved >= 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (variant_id, warehouse_id)
);Key indexes
CREATE INDEX idx_categories_path ON categories USING GIST (path)
LTREE GIST index makes 'all descendants of category X' an O(log n) subtree query for nested navigation.
CREATE INDEX idx_products_attrs ON products USING GIN (attributes jsonb_path_ops)
Faceted filters ('material=cotton' AND 'fit=slim') hit JSONB without per-attribute columns.
CREATE INDEX idx_variants_product ON variants(product_id) WHERE active
Product detail page loads only active variants; partial index keeps it tight.
CREATE INDEX idx_prices_current ON prices(variant_id, currency) WHERE effective_to IS NULL
Current-price lookup is the hottest query; partial index ignores historical rows.
CREATE INDEX idx_inventory_available ON inventory(variant_id) WHERE on_hand > reserved
'In stock somewhere' check becomes a partial-index hit instead of a sum across warehouses.
Key decisions to defend
- Variant options live in JSONB rather than a fixed (size, color) shape — t-shirts and lightbulbs share one schema without polymorphism.
- Prices are temporal (effective_from / effective_to) instead of overwritten — audit, scheduled sales, and rollback all fall out for free.
- Categories are a tree via LTREE rather than recursive CTE (Common Table Expression) only — subtree queries are 100x faster and worth the extension dependency.
- Inventory is split into on_hand and reserved so cart holds do not require a separate ledger; a SELECT FOR UPDATE on the row gates the decrement.
- Many-to-many product_categories table — a strict tree forces awkward duplication when products legitimately belong to multiple browse paths.
Likely follow-ups
- · Add a global search ranked by popularity — where does the score live?
- · How would you represent a bundle (3 products sold as one SKU)?
- · Black Friday: one variant gets 10k concurrent decrement attempts — what changes?
- · Add per-region pricing rules without writing every (variant, country) row.
Dive deeper
Catalog questions probe whether you reach for JSONB intelligently (variant options, attributes) versus reflexively (everything in a blob). They also test whether you model time correctly — prices and inventory are state that changes, and a senior candidate models the history, not just the current value. The inventory question quietly tests concurrency awareness.