Warming up the neural circuits...
By the end of this chapter you will:
You can rename a class in 30 seconds. Renaming a column in a production database with 10 million rows requires: a migration, a code change, dual-writing during rollout, a backfill job, and a second migration to remove the old column. That's days of work.
Code is easy to change. Database schemas — especially with live data — are hard.
This is why you spend a full day thinking about the schema before you write any code.
This is the single most common mistake new developers make.
❌ Wrong approach: "The dashboard shows a user's name, their favourite restaurants, and their order count. Let me create a user_dashboard table with all those fields."
You are designing for a screen that will change every sprint. The screen is temporary. The data model is permanent.
✅ Right approach: Identify the real things (entities) in the business:
These entities and their relationships are the truth. The dashboard is just a view over that truth.
Before you create a new table, answer these questions. If you can't answer them, you're not ready.
Use UUID as the primary key by default. It's globally unique, safe to expose in URLs, and doesn't leak how many records you have.
id UUID PRIMARY KEY DEFAULT gen_random_uuid()Owned data lives here or in a child table:
-- Order owns its items
order_items (id, order_id REFERENCES orders(id) ON DELETE CASCADE, ...)Referenced data lives in another table:
-- Order references a user, but doesn't own the user
orders (id, user_id REFERENCES users(id) ON DELETE RESTRICT, ...)ON DELETE CASCADE = if the parent is deleted, delete children too (ownership)
ON DELETE RESTRICT = if something still references this, refuse to delete it
Encode rules as database constraints, not just application code:
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected', 'completed'))Now even if a bug tries to set status = 'banana', the database rejects it.
Write your top 3 SELECT queries before creating the table. Then create indexes for them.
High write traffic → fewer indexes (each index slows down inserts).
High read traffic → more indexes (each speeds up selects).
Tables: snake_case, plural
users
restaurants
menu_items
order_itemsColumns: snake_case
created_at
user_id
is_open
total_amountBoolean columns: prefix with is_ or has_
is_active
is_open
has_deliveryForeign keys: <referenced_table_singular>_id
user_id → references users(id)
restaurant_id → references restaurants(id)
menu_item_id → references menu_items(id)Timestamps: always created_at, updated_at, and optionally deleted_at
Junction tables (many-to-many): alphabetical, both names
restaurant_categories (not categories_restaurants)| What you're storing | Use this type | Why |
|---|---|---|
| Primary key | UUID | Globally unique, safe in URLs |
| Money / amounts | NUMERIC(19, 6) | Never use float — float arithmetic loses cents |
| Percentages | NUMERIC(5, 4) | Same reason |
| Short text (codes, slugs) | VARCHAR(N) with explicit max | Prevents pathological 10 MB inputs |
| Long text | TEXT | No limit needed |
| Status / enum values | VARCHAR(20) + CHECK constraint | Flexible (add values without a migration) |
| Timestamps | TIMESTAMPTZ | Always timezone-aware. Never plain TIMESTAMP. |
| Dates (birthdate, holidays) | DATE | No time component |
| JSON blobs | JSONB | Indexable, queryable. Not . |
Never store money as a JavaScript number (float). 0.1 + 0.2 in is 0.30000000000000004. Use NUMERIC(19,6) in the DB. Never store dates as strings — you can't sort or compare them. Never use plain JSON — always JSONB.
Here is a real QuickBite orders table with proper constraints:
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
restaurant_id UUID NOT NULL REFERENCES restaurants(id) ON DELETE RESTRICT,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending',
Notice:
NOT NULL or a default — no accidental nullsstatus is constrained at the DB level — bad data can’t sneak in from a consoletotal_amount > 0 — zero-amount orders are rejected at the database levelBad (denormalized):
orders (id, user_id, user_name, user_email, restaurant_name, total_amount, ...)Now if the user updates their name, you have to update every order row. Miss one — your data is inconsistent.
Good (normalized):
orders (id, user_id, restaurant_id, total_amount, ...)
-- To get user name, JOIN to users table
-- To get restaurant name, JOIN to restaurants tableWhen is denormalization OK?
Only when you have a measured performance problem AND the denormalized field is derivable. Name it to make the intent obvious: restaurant_name_at_order_time — clearly a snapshot, not a live value.
Hard delete: Row is gone. Simple. Recommended for most tables.
Soft delete: Row has a deleted_at column. When "deleted", set deleted_at = now(). Used when:
deleted_at TIMESTAMPTZ -- NULL means active; set to now() to "delete"With Sequelize, paranoid: true automatically adds WHERE deleted_at IS NULL to all queries.
Pick one strategy per table and never mix. Having is_active = false AND status = 'archived' AND deleted_at IS NOT NULL on different tables in the same app means you now have 3 different meanings for "is this record still live?"
Every table that holds business must have these columns:
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID, -- FK to users table (NULL if system-created)
updated_by UUID -- FK to users tableFor important tables (orders, payments), add an audit log table too:
CREATE TABLE order_status_changes (
id BIGSERIAL PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
from_status VARCHAR(20) NOT NULL,
to_status VARCHAR(20) NOT NULL,
changed_by UUID NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reason TEXT
);When a customer asks “why was my order cancelled?” you need to answer. Without this table, you can’t.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Designing tables from screen mockups | Screens change; data models don't | Design from business entities |
Storing money as FLOAT | 0.1 + 0.2 = 0.30000000000000004 | Use NUMERIC(19,6) |
No CHECK constraints on status columns | Bad values sneak in via direct SQL | Add CHECK (status IN (...)) |
Mixing is_active and deleted_at on same table | Two meanings for "inactive" | Pick one soft-delete strategy |
| No audit columns | Can't answer "who changed this?" | Always add created_at, updated_at |
Design your database from business entities and their relationships — not from screens. Screens change every sprint. A well-designed schema can survive years of product changes without major restructuring.
| True/false | BOOLEAN | NOT NULL DEFAULT false unless unknown is possible |