Lesson 22 of 45 intermediate

Database Design

Shape your tables so wrong data cannot get in

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

Why is a random GUID a poor clustered key in SQL Server?

Open interactive version (quiz + challenge)

Real-world analogy

Think of a building's plans. The foundation and load-bearing walls are keys and constraints: they hold even when a careless contractor makes a mistake. A rule written only in the contractor's notebook is the same as a rule in application code. One day two contractors work at the same moment, and both break it. The walls are constraints that the database enforces itself, and the notebook is a check in code only.

What is it?

Database design is choosing tables, keys, types and constraints so the data stays correct and queries stay simple. Normalization splits data so each fact lives in one place, which prevents the same value being updated in three tables. Constraints let the database reject impossible rows, whatever code or script tries to write them. Good design is mostly boring: clear keys, the right types and rules that the database enforces itself.

Real-world relevance

The expensive mistakes are quiet. Prices stored as float drift by a cent and the invoices stop matching. An "email must be unique" rule exists only in C# and two quick sign-ups create duplicate accounts. A random GUID clustered key makes inserts slow as the table grows. Each one is cheap to prevent on day one and painful to repair across a table with millions of rows.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

Your app checks "email already exists" in C# before inserting, with no UNIQUE constraint. What can go wrong?

Which type should you use for a price?

Code example

-- SQL Server (T-SQL)
CREATE TABLE dbo.Customers (
    Id    int IDENTITY(1,1) PRIMARY KEY,
    Email nvarchar(254) NOT NULL UNIQUE,
    Name  nvarchar(100) NOT NULL
);

CREATE TABLE dbo.Products (
    Id   int IDENTITY(1,1) PRIMARY KEY,
    Name nvarchar(100) NOT NULL
);

CREATE TABLE dbo.Orders (
    Id         int IDENTITY(1,1) PRIMARY KEY,
    CustomerId int NOT NULL REFERENCES dbo.Customers (Id),
    Status     varchar(20) NOT NULL CHECK (Status IN ('New', 'Paid', 'Shipped')),
    CreatedAt  datetime2 NOT NULL DEFAULT SYSUTCDATETIME()
);

-- Join table: many orders have many products
CREATE TABLE dbo.OrderItems (
    OrderId   int NOT NULL REFERENCES dbo.Orders (Id) ON DELETE CASCADE,
    ProductId int NOT NULL REFERENCES dbo.Products (Id),
    Quantity  int NOT NULL CHECK (Quantity > 0),
    UnitPrice decimal(10, 2) NOT NULL CHECK (UnitPrice >= 0),   -- price at the time of sale
    PRIMARY KEY (OrderId, ProductId)
);

-- Foreign keys are not indexed automatically; this one serves "orders of a customer"
CREATE INDEX IX_Orders_CustomerId ON dbo.Orders (CustomerId);

Line-by-line walkthrough

  1. 1. Customers has an identity key. Email is NOT NULL and UNIQUE, so the database itself blocks duplicates.
  2. 2. Products is a small lookup table with its own key.
  3. 3. Orders points to a customer with a foreign key. The CHECK allows only three status values, and CreatedAt defaults to UTC time.
  4. 4. OrderItems is the join table between orders and products. Its composite primary key stops the same product twice in one order.
  5. 5. Quantity and UnitPrice have CHECK rules, and UnitPrice is decimal(10, 2) so money does not drift.
  6. 6. The final index covers the foreign key on Orders, because the database does not add one by itself.

Spot the bug

CREATE TABLE dbo.Users (
    Id    int IDENTITY(1,1) PRIMARY KEY,
    Email nvarchar(254) NOT NULL
);

-- Application code runs before each insert:
-- if (!await db.Users.AnyAsync(u => u.Email == email)) { insert the user }
Need a hint?
What if two sign-up requests run this check at the same instant?
Show answer
Uniqueness is only checked in code, so two requests can both see no match and both insert, leaving duplicate emails. Fix: add a UNIQUE constraint on Email and handle the duplicate key error in code. The database settles the race, because only one insert can win.

Explain like I'm 5

A database is like a toy box with labelled sections and rules on the lid. The rules say "no empty labels" and "no two toys with the same name". The box checks every toy that goes in, even if the person putting it in forgets the rules.

Fun fact

Neither SQL Server nor PostgreSQL creates an index on a foreign key column by itself. EF Core migrations add one by convention, but hand-written SQL does not. Many slow joins are just a missing index on the child table.

Hands-on challenge

Say it out loud in 30 seconds: "How do you design a database so that bad data cannot get in?" Then check: did you mention keys and foreign keys, NOT NULL, UNIQUE and CHECK constraints, decimal for money, and normalization with a reason for denormalizing?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook