Skip to main content

📐 Database Design Principles

You can create tables and query them — but a badly shaped database will haunt an app for years: slow queries, contradictory data, migrations that break in production. Database design is the architecture stage. Get the structure right and everything you build on top gets easier.

Week 8 · Day 1 (Monday: Introduction to Databases) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Walk through the stages of the database design process
  • Draw an entity-relationship (ER) diagram with entities, attributes, and cardinality
  • Distinguish one-to-one, one-to-many, and many-to-many relationships
  • Resolve a many-to-many relationship with a junction table
  • Normalize a table through 1NF, 2NF, and 3NF and explain what each removes
  • Decide when deliberate denormalization is the right trade-off

Estimated Time: 70 minutes

Practice: Model a blog with an ER diagram, then normalize a messy orders table step-by-step to 3NF.

In This Lesson

Why Design Matters

Database design is to software what architecture is to a building. Bad architectural choices produce cramped rooms, cracked foundations, and renovations that cost more than the original build. Bad database design produces slow queries, data that contradicts itself, and features that become terrifying to change.

A well-designed schema pays you back every day. It:

  • Keeps data consistent — one fact lives in exactly one place
  • Prevents anomalies when you insert, update, or delete
  • Makes common queries fast and simple to write
  • Adapts gracefully as requirements grow

We'll focus on relational design, where the discipline is most developed, and note where NoSQL flips the rules.

The Design Process

Good design moves from fuzzy requirements to a concrete schema in orderly steps. You rarely do all this ceremony for a tiny app, but knowing the stages keeps you honest.

graph LR A[Requirements] --> B[Conceptual: ER model] B --> C[Logical: tables & keys] C --> D[Normalization] D --> E[Physical: types & indexes] E --> F[Implement & refine]
  1. Requirements — what data must we store, and how will it be queried?
  2. Conceptual design — sketch the entities and their relationships as an ER model.
  3. Logical design — turn entities into tables with primary and foreign keys.
  4. Normalization — reorganize to remove redundancy.
  5. Physical design — choose concrete types, add indexes for the queries you'll run.

💡 Start from the questions

The single most useful design question is: "What questions will the app ask this data?" A schema that makes your common queries easy and fast is a good schema, whatever the textbook says.

Entity-Relationship Modeling

An entity-relationship (ER) diagram is a picture of your data's structure. It has three ingredients:

  • Entities — the "things" you track: Customer, Order, Product. Each becomes a table.
  • Attributes — properties of an entity: a Customer's name and email. Each becomes a column.
  • Relationships — how entities connect: a Customer places Orders.

Here's an ER diagram for a small e-commerce store. Read ||--o{ as "one to many":

erDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ ORDER_ITEM : contains PRODUCT ||--o{ ORDER_ITEM : "ordered as" CATEGORY ||--o{ PRODUCT : groups CUSTOMER { int id string name string email } ORDER { int id int customer_id date order_date string status } PRODUCT { int id string name decimal price int category_id } CATEGORY { int id string name } ORDER_ITEM { int order_id int product_id int quantity decimal unit_price }

Every relationship is enforced by a foreign key: ORDER.customer_id points at CUSTOMER.id, PRODUCT.category_id at CATEGORY.id, and so on. The diagram is the blueprint; the foreign keys are the steel that holds it together.

Relationship Types

Cardinality describes how many instances of one entity relate to another. There are three shapes, and recognizing them tells you exactly how to lay out your tables.

One-to-one, one-to-many, and many-to-many relationships One-to-One (1:1) User 1 — 1 Profile One-to-Many (1:N) Customer 1 — N Order Many-to-Many (M:N) Student M — N Course
The three cardinalities. 1:N is by far the most common — one customer, many orders.
TypeExampleHow to implement
One-to-OneUser ↔ ProfileForeign key on either table (often with a UNIQUE constraint)
One-to-ManyCustomer → OrdersForeign key on the "many" side (orders.customer_id)
Many-to-ManyStudents ↔ CoursesA third junction table — see below

Resolving Many-to-Many

A relational table can't hold "many" foreign keys in one column. So a many-to-many relationship is always broken into two one-to-many relationships using a junction table (also called a join or bridge table) in the middle.

Students take many courses; each course has many students. The enrollments table sits between them:

erDiagram STUDENT ||--o{ ENROLLMENT : has COURSE ||--o{ ENROLLMENT : has ENROLLMENT { int student_id int course_id date enrolled_on string grade }
CREATE TABLE students (
    id    SERIAL PRIMARY KEY,
    name  VARCHAR(100) NOT NULL
);

CREATE TABLE courses (
    id    SERIAL PRIMARY KEY,
    title VARCHAR(100) NOT NULL
);

-- The junction table: one row per (student, course) pairing
CREATE TABLE enrollments (
    student_id  INTEGER REFERENCES students(id),
    course_id   INTEGER REFERENCES courses(id),
    enrolled_on DATE,
    grade       VARCHAR(2),
    PRIMARY KEY (student_id, course_id)   -- composite key prevents duplicates
);

The composite primary key (student_id, course_id) makes each pairing unique — a student can't enroll in the same course twice — and the junction table has a natural home for facts about the pairing itself, like the grade and enrollment date.

Normalization: 1NF, 2NF, 3NF

Normalization is the systematic process of organizing columns and tables to minimize redundancy. Each "normal form" is a rule; you apply them in order, and each one removes a specific kind of duplication that causes update problems.

💡 Why redundancy is dangerous

If a customer's email is copied into all 50 of their order rows and they change it, you must update 50 places. Miss one and your data now contradicts itself — that's an update anomaly. Normalization stores each fact once so there's nothing to keep in sync.

First Normal Form (1NF)

Rule: every cell holds a single, atomic value — no lists crammed into one column — and each row is unique.

Not in 1NF (a comma-separated list in one cell):

StudentCourses
JohnMath, Physics, Chemistry
JaneBiology, Chemistry

In 1NF (one value per cell, one course per row):

StudentCourse
JohnMath
JohnPhysics
JohnChemistry
JaneBiology
JaneChemistry

Second Normal Form (2NF)

Rule: be in 1NF, and every non-key column must depend on the whole primary key — not just part of it. This only bites when you have a composite primary key.

Not in 2NF: the key is (student_id, course_id), but student_name depends only on student_id:

student_idcourse_idstudent_namegrade
1101John SmithA
1102John SmithB+

Problem: "John Smith" is repeated on every enrollment. Fix: split the student's own facts into a students table, leaving only pairing facts (the grade) in enrollments.

Third Normal Form (3NF)

Rule: be in 2NF, and no non-key column may depend on another non-key column (a "transitive" dependency).

Not in 3NF: category_name depends on category_id, which is not the key:

product_idproduct_namecategory_idcategory_name
5iPhone3Electronics
6Galaxy3Electronics

Problem: rename the category and you must edit every product in it. Fix: move category names into their own table; products keep just category_id.

Worked example: normalizing an orders table to 3NF

Start with one bloated table jamming customer, product, and order data together:

-- ❌ Everything in one table: customer & product data repeat on every line
CREATE TABLE customer_orders (
    order_id      INT,
    order_date    DATE,
    customer_name VARCHAR(100),
    customer_email VARCHAR(100),
    product_id    INT,
    product_name  VARCHAR(100),
    product_price DECIMAL(10,2),
    quantity      INT
);

After decomposing to 3NF, each fact lives exactly once across four focused tables:

CREATE TABLE customers (
    id    SERIAL PRIMARY KEY,
    name  VARCHAR(100),
    email VARCHAR(100) UNIQUE
);

CREATE TABLE products (
    id    SERIAL PRIMARY KEY,
    name  VARCHAR(100),
    price DECIMAL(10,2)
);

CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    order_date  DATE,
    customer_id INT REFERENCES customers(id)
);

CREATE TABLE order_items (
    order_id   INT REFERENCES orders(id),
    product_id INT REFERENCES products(id),
    quantity   INT,
    unit_price DECIMAL(10,2),         -- price AT TIME OF SALE, kept on purpose
    PRIMARY KEY (order_id, product_id)
);

Now a customer's email is stored once no matter how many orders they place, and a product's details once no matter how many orders include it. Note unit_price is deliberately copied into order_items: the price the customer paid is a fact about that order and must not change when the product's current price does.

When to Denormalize

Normalization is the default, but it isn't a religion. Highly normalized data means more JOINs, and on read-heavy workloads those joins can become a bottleneck. Denormalization deliberately re-introduces some redundancy to make reads faster.

Reasonable reasons to denormalize

  • Reporting and analytics dashboards that read constantly but rarely write
  • A query joins many tables and has become measurably slow
  • The data changes rarely, so keeping copies in sync is cheap

Common techniques

  • Pre-computed values — store an order_total instead of summing items every read
  • Summary tables — a nightly-built table of aggregated stats
  • Materialized views — cache a complex query's result on disk

⚠️ Denormalize last, not first

Every duplicated value is now something you must keep consistent by hand — the exact anomaly normalization removed. Reach for better indexes and query tuning first. Denormalize only when you have measured a real read bottleneck, and document every redundant column so future-you knows it must be kept in sync.

💡 NoSQL flips the default

Document databases like MongoDB often start denormalized — embedding a customer's address right in each order — because there are no joins and reads should fetch everything at once. The trade-off (harder updates across copies) is the same; NoSQL just picks the other side of it by default.

Practice & Quiz

🏋️ Exercise 1: Model a blog

Goal: A blog has users who write posts, and posts that receive comments. Identify the entities, their relationships and cardinality, then list the tables and their keys.

💡 Hint

One user writes many posts (1:N). One post has many comments (1:N). Each comment is written by a user (1:N). Every "many" side needs a foreign key pointing back to the "one" side.

✅ Solution

Three entities, all one-to-many:

CREATE TABLE users (
    id   SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE posts (
    id       SERIAL PRIMARY KEY,
    user_id  INT REFERENCES users(id),   -- author
    title    VARCHAR(200) NOT NULL,
    body     TEXT
);

CREATE TABLE comments (
    id       SERIAL PRIMARY KEY,
    post_id  INT REFERENCES posts(id),   -- which post
    user_id  INT REFERENCES users(id),   -- who commented
    body     TEXT
);

🏋️ Exercise 2: Spot the normalization violation

Goal: This table is in 1NF but not 3NF. Name the offending column and the form it violates, then propose the fix.

CREATE TABLE employees (
    emp_id        INT PRIMARY KEY,
    emp_name      VARCHAR(100),
    dept_id       INT,
    dept_name     VARCHAR(100)   -- hmm...
);
💡 Hint

Which non-key column depends on another non-key column rather than on emp_id?

✅ Solution

dept_name violates 3NF: it depends on dept_id (a non-key column), not directly on the primary key emp_id. Rename a department and you'd have to edit every employee in it. Fix by splitting departments into their own table:

CREATE TABLE departments (
    id   SERIAL PRIMARY KEY,
    name VARCHAR(100)
);

CREATE TABLE employees (
    emp_id   INT PRIMARY KEY,
    emp_name VARCHAR(100),
    dept_id  INT REFERENCES departments(id)   -- name lives once, over there
);

🎯 Quick Quiz

Question 1: How do you implement a many-to-many relationship in a relational database?

Question 2: A cell containing "Math, Physics, Chemistry" violates which normal form?

Question 3: When is denormalization a reasonable choice?

Best Practices & Pitfalls

✅ Do

  • Start from the questions your app will ask the data
  • Normalize to 3NF by default — it's the right choice for most apps
  • Give every table a primary key and enforce relationships with foreign keys
  • Use a junction table for every many-to-many relationship
  • Adopt consistent naming (e.g. singular or plural table names, snake_case) and stick to it

❌ Don't

  • Store lists or CSV strings in a single column — that's an unnormalized trap
  • Copy the same fact into many rows and hope to keep them in sync
  • Denormalize before you've measured an actual performance problem
  • Forget that a paid price on an order is a real, separate fact from the product's current price

✅ The pragmatic rule

Normalize first, denormalize later — and only with evidence. A clean 3NF schema plus well-chosen indexes handles the overwhelming majority of applications you'll build.

Summary

🎉 Key Takeaways

  • Design moves from requirements → ER model → tables → normalization → physical design
  • An ER diagram captures entities, attributes, and relationship cardinality
  • Relationships are 1:1, 1:N, or M:N — and M:N always needs a junction table
  • Normalization stores each fact once: 1NF (atomic cells), 2NF (no partial key dependency), 3NF (no transitive dependency)
  • Denormalize only deliberately, for measured read performance, and document the redundancy

📚 Additional Resources

🚀 What's Next?

Theory becomes practice next: Setting up PostgreSQL. You'll install a real database, create the tables you designed here, and get ready to connect it to a Node.js application.

🎉 You think in schemas now!

Knowing how to shape data well is the skill that separates a working app from one that keeps working as it grows.