Expert: design, speed and engines · Schema design and normalisation · topic 46 of 62 · 40 min
Design project: an online store
Explanation
Step 1: the requirements
A small brand wants its own store website, selling groceries, kitchenware and electronics across India. After talking to the owner, you write these sentences:
- Customers sign up with a name and a unique email. They can save several delivery addresses.
- Products belong to one category and have a price. Prices change during sales.
- An order belongs to one customer, is delivered to one of their addresses, and contains one or more products with quantities.
- An order moves through statuses: Placed, Paid, Shipped, Delivered, or Cancelled.
- A customer pays by UPI, card or cash on delivery. A payment can fail and be retried.
- Customers rate products from 1 to 5 stars, at most once per product, with an optional comment.
Step 2: entities and relationships
customers ||--o< addresses a customer saves zero or many addresses
customers ||--o< orders a customer places zero or many orders
addresses ||--o< orders an order ships to one address
categories ||--o< products a product is in one category
orders ||--|< order_items an order has one or many lines
products ||--o< order_items a product appears in many lines
orders ||--o< payments an order can have several payment attempts
products ||--o< reviews >o--|| customers a review links one product and one customerStep 3: tables, keys and constraints
CREATE TABLE products (
id INTEGER PRIMARY KEY,
category_id INTEGER NOT NULL REFERENCES categories(id),
name TEXT NOT NULL,
price_paise INTEGER NOT NULL CHECK (price_paise > 0)
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
address_id INTEGER NOT NULL REFERENCES addresses(id),
status TEXT NOT NULL CHECK (status IN ('Placed', 'Paid', 'Shipped', 'Delivered', 'Cancelled')),
created_at TEXT NOT NULL -- UTC
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(id),
product_id INTEGER NOT NULL REFERENCES products(id),
qty INTEGER NOT NULL CHECK (qty > 0),
unit_price_paise INTEGER NOT NULL, -- the price paid, copied at order time
PRIMARY KEY (order_id, product_id)
);
CREATE TABLE reviews (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id),
customer_id INTEGER NOT NULL REFERENCES customers(id),
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
body TEXT,
UNIQUE (product_id, customer_id) -- one review per product per customer
);The design decisions, and why
- Money is integer paise everywhere (topic 4), so totals and payments match exactly.
- order_items copies unit_price_paise, because "the price paid" is a different fact from today's price (topic 2). A sale next month must not change old bills.
- payments is its own table, not columns on orders, because one order can have a failed UPI attempt and then a successful card payment.
- Requirement 6 becomes UNIQUE (product_id, customer_id) on reviews: the rule lives in the database, not only in the app.
- addresses are treated as append-only: editing an address creates a new row, so an old order still points to where it really went. Many real stores go further and copy the shipping address into the order.
- Every status and rating is limited by CHECK, every foreign key is NOT NULL because each link is required, and the email is UNIQUE.
Step 4: test the design with real questions
A design is not finished when the tables exist. It is finished when it answers the business questions simply. Write the reports the owner will want and run them against sample data:
- Revenue per category for paid, shipped and delivered orders.
- Average rating and number of reviews per product, including products with no reviews.
- Orders whose payments do not cover the order total (to chase or cancel).
- Orders per delivery city, to plan courier partners.
SELECT c.name AS category,
printf('%.2f', SUM(oi.qty * oi.unit_price_paise) / 100.0) AS revenue_rupees
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
WHERE o.status IN ('Paid', 'Shipped', 'Delivered')
GROUP BY c.id, c.name
ORDER BY c.name;
-- Electronics | 1999.00
-- Grocery | 1780.00
-- Kitchen | 1450.00The next module adds indexes to this design: on every foreign key used in joins (orders.customer_id, order_items.product_id), and on the columns the reports filter by, such as orders.status and orders.created_at.
Common mistakes
- Reading the product's current price for old orders. Fix: copy the price into order_items.
- A payment_status column on orders instead of a payments table. Fix: a table, so every attempt is recorded.
- Forgetting rules the business stated ("one review per customer"). Fix: turn every "at most" or "must" sentence into a constraint.
- Never testing the design with queries. Fix: write the top five reports before you call the schema done.
Need help?
Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.
Try:
Watch the lesson
Design project: an online store · 1/7
Let us design a real database from start to finish: an online store.
Prefer a video by a teacher? Find free videos on “e-commerce database schema design orders order items payments tutorial” on YouTube ↗
In real life: Small brands launching their own stores
Many Indian direct-to-consumer brands sell on marketplaces and also run their own website. Whether they use a hosted platform or build their own, the data underneath has this same shape: customers, addresses, a catalogue, orders with lines, payments with retries, and reviews. Developers who can sketch this schema in an interview, and explain why the price is copied into order lines, show that they understand real e-commerce data.
SELECT o.id AS order_id,
SUM(oi.qty * oi.unit_price_paise) AS order_paise,
COALESCE((SELECT SUM(p.amount_paise) FROM payments p WHERE p.order_id = o.id), 0) AS paid_paise
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status <> 'Cancelled'
GROUP BY o.id
HAVING paid_paise < order_paise;Practice (0/4 done)
Start the store from an empty database. 1) Create categories: id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE. 2) Create products: id INTEGER PRIMARY KEY, category_id INTEGER NOT NULL REFERENCES categories(id), name TEXT NOT NULL, price_paise INTEGER NOT NULL CHECK (price_paise > 0). 3) Insert categories (1, 'Grocery'), (2, 'Kitchen'), (3, 'Fashion') and products (1, 1, 'Basmati Rice 5kg', 65000), (2, 1, 'Toor Dal 1kg', 16000), (3, 2, 'Pressure Cooker 3L', 145000). 4) Return every category with its number of products, including categories with none: columns category and products, sorted by category.
The full store database is loaded (customers, addresses, categories, products, orders, order_items, payments, reviews). For every product, including those with no reviews, return name, reviews (the number of reviews) and avg_rating (the average rating rounded to 1 decimal place; NULL when there are no reviews), sorted by name.
On the store database, report revenue per category for orders with status Paid, Shipped or Delivered. Revenue is the sum of qty * unit_price_paise, shown in rupees with two decimals as text using printf('%.2f', ... / 100.0). Return category and revenue_rupees, sorted by category.
Review a schema like a senior engineer. Take the store design of this topic (or a schema from your own project) and go through this checklist, writing one line per point: what you found and what you would change. Then add one new requirement of your own (for example coupons, wishlists or product variants such as sizes) and design the tables for it.
Quick quiz
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Denormalisation, OLTP and OLAPSchema design and normalisation · same module
- Normalisation: 1NF, 2NF, 3NF and BCNFSchema design and normalisation · same module
- Designing the data modelFull-Stack Development · Planning an app
- Keys and relationshipsDatabases and the relational model
- Project: e-commerce analytics, and your next stepsInterview questions and practice projects
- CREATE TABLE and constraintsDatabases and the relational model
Questions people ask about this
Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.
Why does order_items store unit_price_paise when products already has price_paise?
Answer: Because the price paid must not change when the product's price changes later
The price on a bill is a fact of that moment. Copy it at order time.
A customer may try UPI, fail, and then pay by card. How should payments be stored?
Answer: A payments table with one row per attempt and an order_id foreign key
One-to-many: an order can have several payment rows.
Which constraint enforces "a customer can review a product only once"?
Answer: UNIQUE (product_id, customer_id) on reviews
The UNIQUE pair rejects a second review by the same customer for the same product.
When is a schema design really finished?
Answer: When the important business questions can be answered with clear queries on sample data
Testing with real reports finds missing columns and awkward relationships early.
To get each customer's product categories in SHOP, which tables must the query pass through?Related
Answer: customers, orders, order_items and products
Keys connect customers → orders → order_items → products; you cannot skip a link.
In the Notes app, why is user_id stored in the notes table?Related
Answer: It is a foreign key that says which user owns the note
One user has many notes, so each note row records its owner with a foreign key pointing to users.id.
courses has both id and a unique code like CS101. What is the usual design?Related
Answer: Use id as the primary key and put a UNIQUE rule on code
Both are candidate keys. A surrogate id is the stable key; UNIQUE still protects the natural value.
In SHOP, why is the primary key of order_items (order_id, product_id)?Related
Answer: Because the pair is unique, while each column alone repeats
An order has many products and a product is in many orders, but each pair appears once.
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.
Next steps
- This is the last topic of “Schema design and normalisation”. The module exam comes next.
- Module 9 exam: Schema design and normalisation · 13 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, Indexes, query plans and views. 62 topics left in the course before the final exam.