Good schema design prevents bugs before the first SELECT runs. You model entities (Customer, Order), their relationships (one customer has many orders), and enforce rules with constraints — not application code alone.
For OLTP systems (web apps, APIs), aim for 3rd Normal Form (3NF) by default. Denormalize deliberately for reporting — never accidentally.
2.1
Conceptual → logical → physical
Design in three passes. Sketch entities on a whiteboard first; normalize into tables second; pick types, indexes and filegroups third.
-- 1. CONCEPTUAL (whiteboard / ER diagram)
-- Entities: Customer, Product, Order, OrderLine
-- Relationships: Customer 1──* Order, Order 1──* OrderLine, Product *──* Category
-- 2. LOGICAL (tables, keys, FKs — no performance tuning yet)
-- One table per entity, junction table for M:N
-- 3. PHYSICAL (SQL Server specifics)
-- INT IDENTITY PKs, DECIMAL for money, indexes on FK columns,
-- filtered indexes, partitioning only when you have proof you need it
-- ER sketch (Order domain):
--
-- Customer ──────< Order ──────< OrderLine >────── Product
-- │ │
-- │ └── Invoice (0..1)
-- └── Address (1..*)
2.2
Normalization — 1NF, 2NF, 3NF
Each normal form removes a class of update anomaly. Most OLTP schemas stop at 3NF; go further (BCNF) only when you hit real redundancy problems.
-- ── 1NF: atomic values, no repeating groups ─
-- ❌ Tags stored as CSV — can't index, can't JOIN
Tags VARCHAR(500) -- 'sql,tsql,database'
-- ✅ One row per tag
CREATE TABLE dbo.ArticleTag (
ArticleId INT NOT NULL,
Tag VARCHAR(50) NOT NULL,
CONSTRAINT PK_ArticleTag PRIMARY KEY (ArticleId, Tag)
);
-- ── 2NF: no partial dependency on composite PK ─
-- ❌ OrderLine(OrderId, LineNum, ProductName, Qty)
-- ProductName depends only on ProductId, not full PK
-- ✅ ProductName lives on Product table
CREATE TABLE dbo.OrderLine (
OrderId INT NOT NULL,
LineNum SMALLINT NOT NULL,
ProductId INT NOT NULL,
Quantity INT NOT NULL,
UnitPrice DECIMAL(10,2) NOT NULL, -- price at time of order
CONSTRAINT PK_OrderLine PRIMARY KEY (OrderId, LineNum),
CONSTRAINT FK_OrderLine_Order FOREIGN KEY (OrderId) REFERENCES dbo.[Order](OrderId),
CONSTRAINT FK_OrderLine_Product FOREIGN KEY (ProductId) REFERENCES dbo.Product(ProductId)
);
-- ── 3NF: no transitive dependency ─
-- ❌ Customer(ZipCode → City) — City depends on Zip, not CustomerId
-- ✅ Lookup table
CREATE TABLE dbo.ZipCode (
ZipCode CHAR(5) NOT NULL PRIMARY KEY,
City NVARCHAR(100) NOT NULL,
Region NVARCHAR(100) NOT NULL
);
2.3
Relationships & cardinality
-- 1:N (most common) — FK on the "many" side
CREATE TABLE dbo.[Order] (
OrderId INT NOT NULL IDENTITY PRIMARY KEY,
CustomerId INT NOT NULL,
OrderDate DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_Order_Customer FOREIGN KEY (CustomerId)
REFERENCES dbo.Customer(CustomerId)
);
-- 1:1 — FK + UNIQUE on child (e.g. User ↔ Profile)
CREATE TABLE dbo.UserProfile (
UserId INT NOT NULL PRIMARY KEY,
Bio NVARCHAR(500) NULL,
CONSTRAINT FK_UserProfile_User FOREIGN KEY (UserId)
REFERENCES dbo.[User](UserId)
);
-- M:N — junction / bridge / associative table
CREATE TABLE dbo.ProductCategory (
ProductId INT NOT NULL,
CategoryId INT NOT NULL,
SortOrder TINYINT NOT NULL DEFAULT 0,
CONSTRAINT PK_ProductCategory PRIMARY KEY (ProductId, CategoryId),
CONSTRAINT FK_PC_Product FOREIGN KEY (ProductId) REFERENCES dbo.Product(ProductId),
CONSTRAINT FK_PC_Category FOREIGN KEY (CategoryId) REFERENCES dbo.Category(CategoryId)
);
-- Optional relationship — NULLable FK
-- Customer may have no assigned SalesRep
SalesRepId INT NULL REFERENCES dbo.Employee(EmployeeId)
-- ON DELETE behaviour (pick explicitly):
-- RESTRICT / NO ACTION — block delete if children exist (safest default)
-- CASCADE — delete children too (dangerous on Order→OrderLine)
-- SET NULL — orphan the child (soft ownership)
2.4
E-commerce schema — end to end
A minimal but production-shaped schema: surrogate keys, audit columns, soft delete, and indexes on every FK column.
CREATE SCHEMA sales AUTHORIZATION dbo; -- domain schema, not everything in dbo
CREATE TABLE sales.Customer (
CustomerId INT NOT NULL IDENTITY(1,1),
Email NVARCHAR(256) NOT NULL,
DisplayName NVARCHAR(200) NOT NULL,
IsDeleted BIT NOT NULL DEFAULT 0,
CreatedAt DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2(7) NULL,
RowVer ROWVERSION NOT NULL,
CONSTRAINT PK_Customer PRIMARY KEY CLUSTERED (CustomerId),
CONSTRAINT UX_Customer_Email UNIQUE (Email) -- natural key as unique, not PK
);
CREATE TABLE sales.Product (
ProductId INT NOT NULL IDENTITY(1,1),
Sku VARCHAR(20) NOT NULL,
Name NVARCHAR(200) NOT NULL,
Price DECIMAL(10,2) NOT NULL CONSTRAINT CK_Product_Price CHECK (Price >= 0),
IsEnabled BIT NOT NULL DEFAULT 1,
CONSTRAINT PK_Product PRIMARY KEY CLUSTERED (ProductId),
CONSTRAINT UX_Product_Sku UNIQUE (Sku)
);
CREATE TABLE sales.[Order] (
OrderId INT NOT NULL IDENTITY(1,1),
CustomerId INT NOT NULL,
Status VARCHAR(20) NOT NULL DEFAULT 'Pending',
Total DECIMAL(12,2) NOT NULL DEFAULT 0,
PlacedAt DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
CONSTRAINT PK_Order PRIMARY KEY CLUSTERED (OrderId),
CONSTRAINT FK_Order_Customer FOREIGN KEY (CustomerId) REFERENCES sales.Customer(CustomerId),
CONSTRAINT CK_Order_Status CHECK (Status IN ('Pending','Paid','Shipped','Cancelled'))
);
CREATE TABLE sales.OrderLine (
OrderId INT NOT NULL,
LineNum SMALLINT NOT NULL,
ProductId INT NOT NULL,
Quantity INT NOT NULL CONSTRAINT CK_Line_Qty CHECK (Quantity > 0),
UnitPrice DECIMAL(10,2) NOT NULL,
CONSTRAINT PK_OrderLine PRIMARY KEY (OrderId, LineNum),
CONSTRAINT FK_Line_Order FOREIGN KEY (OrderId) REFERENCES sales.[Order](OrderId),
CONSTRAINT FK_Line_Product FOREIGN KEY (ProductId) REFERENCES sales.Product(ProductId)
);
-- Index every FK column used in JOINs
CREATE NONCLUSTERED INDEX IX_Order_CustomerId ON sales.[Order](CustomerId);
CREATE NONCLUSTERED INDEX IX_OrderLine_ProductId ON sales.OrderLine(ProductId);
2.5
PK strategy, audit columns & soft delete
-- ── PK strategy ──────────────────────────────
-- ✅ INT IDENTITY — default for OLTP (4 B, sequential inserts)
-- ✅ BIGINT IDENTITY — tables that will exceed 2.1 billion rows
-- ⚠ UNIQUEIDENTIFIER — distributed systems; use NEWSEQUENTIALID() as clustered PK
-- ❌ Natural keys — email/SSN change; use UNIQUE constraint instead
-- ── Audit columns (almost every table) ─────────
CreatedAt DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(),
CreatedBy INT NULL, -- or NVARCHAR(128) for login name
UpdatedAt DATETIME2(7) NULL,
UpdatedBy INT NULL,
-- ── Soft delete (keep history, hide from UI) ───
IsDeleted BIT NOT NULL DEFAULT 0,
DeletedAt DATETIME2(7) NULL,
-- Partial unique index: email unique among active rows only
CREATE UNIQUE INDEX UX_Customer_Email_Active
ON sales.Customer(Email) WHERE IsDeleted = 0;
-- Queries always filter:
SELECT * FROM sales.Customer WHERE IsDeleted = 0;
-- ── Optimistic concurrency ───────────────────────
RowVer ROWVERSION NOT NULL;
-- EF Core: [Timestamp] / IsRowVersion()
-- Manual: UPDATE ... WHERE CustomerId = @id AND RowVer = @ver
2.6
When to denormalize (and when not to)
Denormalization trades write complexity for read speed. Do it with eyes open — document why, and plan how you keep copies in sync.
-- ✅ Snapshot at write time (OrderLine.UnitPrice)
-- Product price changes tomorrow; historical orders stay correct
-- ✅ Denormalized counter maintained by trigger/app
-- Order.LineCount updated when OrderLine rows change — fast reads, controlled writes
-- ✅ Read model / reporting table (CQRS)
-- Nightly ETL flattens Order + Customer + Lines into dbo.OrderReport
-- ❌ Duplicate CustomerName on every Order "for convenience"
-- Update customer name → must fix N orders (3NF violation)
-- ❌ JSON blob replacing proper columns
Payload NVARCHAR(MAX) -- '{"name":"...","price":...}' — un-queryable, no constraints
2.7
Design anti-patterns to avoid
-- ❌ EAV (Entity-Attribute-Value) — "flexible" schema
-- AttributeValue(EntityId, AttributeName, Value) — no types, no FKs, slow
-- ✅ Add columns or use JSON only for truly schemaless extension data
-- ❌ Polymorphic association
-- Comment(EntityType, EntityId) — can't enforce FK, kills index seeks
-- ✅ Separate CommentOnOrder, CommentOnProduct OR typed hierarchy
-- ❌ God table
-- Everything in dbo.Data(Id, Type, JsonPayload)
-- ✅ One table per entity with proper constraints
-- ❌ MONEY / FLOAT for currency
-- ✅ DECIMAL(19,4)
-- ❌ VARCHAR for dates or numbers "because the API sends strings"
-- ✅ Parse at the boundary; store native types
-- ❌ No FK constraints "for performance"
-- You gain microseconds and lose referential integrity forever