Database Design
By the end of this lesson
Design a schema from requirements that keeps queries simple.
A schema is a set of decisions about what the business needs to record. Design is the work of getting from a description in words to those decisions, in an order that does not leave you rewriting the first half.
The goal is worth stating precisely, because it is not "a correct schema". Several correct schemas exist for any requirement. The goal is a schema that makes the queries the application actually runs straightforward to write, and makes the data the business considers invalid impossible to store.
This lesson works through one new requirement against the schema you already have, so the design decisions are concrete rather than abstract.
A sequence that works. Each step produces something the next one needs, which is why the order matters:
Write down the things the business talks about
Read the requirement and underline the nouns. Customer, order, product, return, reason. Each distinct noun that has facts of its own is a candidate table. Nouns that are only ever a single value — a status, a country — may be a column or a small lookup table instead.
Write down the questions the application must answer
"Which returns are awaiting approval?" "What proportion of desk lamps were returned as damaged last quarter?" These are as much a part of the requirement as the nouns, and a design that makes them awkward is the wrong design however tidy it looks.
Fix the relationships and their cardinality
For each pair, ask how many of each can relate to the other, in both directions. An order has many lines; a line belongs to one order. A return refers to one order line; an order line can be returned more than once if partial returns are allowed. That last answer changes the schema, so it has to be asked rather than assumed.
Choose a key for each table
One column, or a small set, that identifies a row and never changes. This is where the natural-versus-surrogate decision belongs, table by table rather than as a blanket policy.
Write the rules as constraints
Every statement in the requirement of the form "must", "cannot" or "only" is a candidate for NOT NULL, UNIQUE, CHECK, a foreign key, or a DEFAULT. Do this while the requirement is in front of you, not afterwards.
Test the design by writing the hard queries
Take the two most awkward questions from step two and write the SQL against your tables. Nothing exposes a bad design faster. If a common question needs five joins and a subquery, change the design now, while changing it costs nothing.
The requirement for this lesson: customers may return items they have ordered. A return refers to a specific line on a specific order, and may cover some or all of the quantity on that line. Every return records a reason from a fixed list — damaged, wrong item, no longer needed, faulty — and the list is maintained by the business. A return is raised, then approved or rejected by an employee, and an approved return may produce a refund. Returns must be reportable by product, by reason and by month.
Working through the steps: the nouns with facts of their own are return and reason. The employee, order line and product already exist. A reason has a code and a description and the business maintains the list, which makes it a lookup table rather than free text. A return refers to one order line, and an order line can have several returns, because partial returns are allowed. The status is a small fixed set with no facts of its own, so it is a column with a constraint rather than a table.
The reason list is the interesting key decision in this design. The earlier lesson on keys compared the two kinds in general; here the question is which one this table should use. The requirement settles it, and the deciding fact is that the business maintains the list — a list somebody edits is a list whose values get renamed, and a key that changes propagates into every row referencing it. So: surrogate key, with a UNIQUE constraint protecting the code. The reasoning matters more than the conclusion, because it runs the other way for a code governed externally: a currencies table keyed on 'GBP', or countries keyed on a two-letter ISO code, is defensible precisely because nobody renames those to suit a report.
| Reason code as the key | Surrogate id, code kept unique | |
|---|---|---|
| The key value | 'DAMAGED' stored on every return row | An integer stored on every return row, code held once |
| Reading rows without a join | The reason is visible in the returns table | A join, or a view, to see anything but a number |
| If the business renames a code | Every referencing row changes, or the old code stays and misleads | One row changes; references are unaffected |
| Width in the child table | Text repeated in every return row, and in every index on it | Four bytes |
| Risk of near-duplicates | 'DAMAGED' and 'Damaged' both insert unless constrained | Same risk, but confined to one small table |
| Reasonable when | The codes are genuinely stable, short, and read constantly without other reason data | The list is business-maintained and may be renamed or reworded |
-- Business-maintained list. Surrogate key, natural code kept unique.
CREATE TABLE return_reasons (
reason_id INT IDENTITY(1,1) NOT NULL,
reason_code NVARCHAR(30) NOT NULL,
description NVARCHAR(200) NOT NULL,
is_active BIT NOT NULL CONSTRAINT df_return_reasons_active DEFAULT (1),
CONSTRAINT pk_return_reasons PRIMARY KEY (reason_id),
CONSTRAINT uq_return_reasons_code UNIQUE (reason_code)
);
CREATE TABLE returns (
return_id INT IDENTITY(1,1) NOT NULL,
order_item_id INT NOT NULL,
reason_id INT NOT NULL,
quantity INT NOT NULL,
status NVARCHAR(20) NOT NULL
CONSTRAINT df_returns_status DEFAULT ('raised'),
raised_on DATETIME2 NOT NULL
CONSTRAINT df_returns_raised DEFAULT (SYSUTCDATETIME()),
decided_on DATETIME2 NULL,
decided_by INT NULL,
refund_amount DECIMAL(10,2) NULL,
CONSTRAINT pk_returns PRIMARY KEY (return_id),
CONSTRAINT fk_returns_order_item FOREIGN KEY (order_item_id)
REFERENCES order_items (order_item_id),
CONSTRAINT fk_returns_reason FOREIGN KEY (reason_id)
REFERENCES return_reasons (reason_id),
CONSTRAINT fk_returns_employee FOREIGN KEY (decided_by)
REFERENCES employees (employee_id),
-- "may cover some or all of the quantity" — never zero, never negative
CONSTRAINT ck_returns_quantity CHECK (quantity > 0),
-- "raised, then approved or rejected" — the only permitted values
CONSTRAINT ck_returns_status CHECK (status IN ('raised', 'approved', 'rejected')),
-- A decision needs both a decider and a date, or neither
CONSTRAINT ck_returns_decision CHECK (
(status = 'raised' AND decided_on IS NULL AND decided_by IS NULL)
OR (status <> 'raised' AND decided_on IS NOT NULL AND decided_by IS NOT NULL)
),
-- Only an approved return carries a refund, and it cannot be negative
CONSTRAINT ck_returns_refund CHECK (
(status = 'approved' AND (refund_amount IS NULL OR refund_amount >= 0))
OR (status <> 'approved' AND refund_amount IS NULL)
)
);
CREATE INDEX ix_returns_order_item ON returns (order_item_id);
CREATE INDEX ix_returns_reason_raised ON returns (reason_id, raised_on);- Look at which columns are nullable and which are not. NOT NULL on order_item_id, reason_id and quantity says a return without them is not a return. decided_by and refund_amount are nullable because they are genuinely unknown while the return is still being considered. Nullability is a design statement, not a default to accept.
- ck_returns_quantity comes straight from "some or all of the quantity". Without it the table accepts a return for zero items, or minus three, and every report that sums quantities has to defend against values the schema should have refused.
- ck_returns_status replaces a comment about permitted values with a rule the database enforces. A typo of 'aproved' is rejected at the moment of the bad write, rather than becoming a row that no report counts and nobody notices. The alternative design is a statuses lookup table with a foreign key, which is better when the list grows or needs display names.
- ck_returns_decision is the constraint most designs omit. It enforces a relationship between columns — a decided return has a decider and a date, an undecided one has neither — which prevents the half-populated rows that make later reporting unreliable.
- The two indexes follow from the questions in step two. Reporting by reason and month uses (reason_id, raised_on); finding the returns against a line uses order_item_id. Each index also costs something on every write, so they are added for named queries rather than on every column that looks useful.
- One rule is deliberately not here: that the returned quantity cannot exceed the quantity ordered on that line. A CHECK constraint can only see columns in its own row, so this needs a trigger or an application-level check inside a transaction. Recognising which rules a CHECK cannot express is part of the design, and quietly leaving such a rule out is how it ends up enforced nowhere.
Test the design against the requirement before you build on it. Signals that it needs another pass:
- A common question needs four or more joins. Sometimes that is correct in a normalised schema; often it means a relationship is modelled a level deeper than it needs to be.
- A query has to parse a string to answer a question. That is a first normal form problem still present in the design.
- You cannot express a requirement as a constraint and have not written down where it is enforced instead. Rules with no home are not rules.
- A column would be NULL for most rows and means something different when it is set. That usually indicates two kinds of thing sharing one table.
- A report needs a value the schema cannot derive, such as "why was this rejected?" when only the status is stored. Go back to the questions list — something was missed.
- Adding one new kind of thing would require a new column, not a new row. products_1 and products_2 is the obvious case; a status_reason_2 column is the same mistake in disguise.
Summary
- Design from the things the business records and the questions it needs answered, not from the screens
- Settle cardinality in both directions before writing any CREATE TABLE — the answer decides the tables
- Decide keys table by table; a business-maintained code is a poor key, an externally governed one can be a good key
- NOT NULL, CHECK, UNIQUE and DEFAULT are where the requirement's rules live, and a CHECK cannot see other rows
- Test the design by writing the two hardest queries before there is any data to migrate
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
New requirement: the distributor stores products in several warehouses. Each warehouse has a name, a city and a manager who is an employee. A product may be held in more than one warehouse, with a different quantity in each. A stock count is recorded each time someone physically counts a product in a warehouse, with the date, the counted quantity and who counted it.
Work through the six steps and produce the CREATE TABLE statements. State the cardinality of each relationship and at least three constraints that come directly from the requirement.
Show solution
Entities: warehouse, and stock count. Product and employee already exist. The quantity held per warehouse is not an entity — it is a fact about the pairing of a product and a warehouse, which is what warehouse_stock holds.
Cardinality: a warehouse has one manager, an employee may manage several warehouses, so manager_id is a column on warehouses. A product is held in many warehouses and a warehouse holds many products, which is a many-to-many and therefore needs the warehouse_stock table. A stock count refers to one product in one warehouse and there are many counts over time, so it is its own table rather than a column.
Constraints from the requirement: quantity_on_hand cannot be negative, so a CHECK. A product appears at most once per warehouse, so a composite primary key on (warehouse_id, product_id) — which also makes accidental duplicates impossible rather than merely discouraged. counted_quantity cannot be negative, and counted_on cannot be missing. The warehouse name is probably unique within the business, so a UNIQUE constraint until somebody tells you otherwise.
The key decision worth defending: warehouse_stock uses the composite key (warehouse_id, product_id) rather than a surrogate id. There is no separate identity for "stock of this product in this warehouse" beyond the pair itself, and the composite key enforces uniqueness for free. Adding a surrogate id here would allow two rows for the same pairing, which is the thing you most want to prevent.
Note also what stock_counts does not do: it does not update warehouse_stock. Whether a count corrects the running quantity is a business rule, not a schema fact, and keeping the count history separate means a discrepancy can be investigated rather than overwritten.
CREATE TABLE warehouses (
warehouse_id INT IDENTITY(1,1) NOT NULL,
name NVARCHAR(100) NOT NULL,
city NVARCHAR(80) NOT NULL,
manager_id INT NULL,
CONSTRAINT pk_warehouses PRIMARY KEY (warehouse_id),
CONSTRAINT uq_warehouses_name UNIQUE (name),
CONSTRAINT fk_warehouses_manager FOREIGN KEY (manager_id)
REFERENCES employees (employee_id)
);
-- The many-to-many between products and warehouses, with a fact of its own
CREATE TABLE warehouse_stock (
warehouse_id INT NOT NULL,
product_id INT NOT NULL,
quantity_on_hand INT NOT NULL CONSTRAINT df_warehouse_stock_qty DEFAULT (0),
CONSTRAINT pk_warehouse_stock PRIMARY KEY (warehouse_id, product_id),
CONSTRAINT fk_warehouse_stock_warehouse FOREIGN KEY (warehouse_id)
REFERENCES warehouses (warehouse_id),
CONSTRAINT fk_warehouse_stock_product FOREIGN KEY (product_id)
REFERENCES products (product_id),
CONSTRAINT ck_warehouse_stock_qty CHECK (quantity_on_hand >= 0)
);
CREATE TABLE stock_counts (
stock_count_id INT IDENTITY(1,1) NOT NULL,
warehouse_id INT NOT NULL,
product_id INT NOT NULL,
counted_quantity INT NOT NULL,
counted_on DATE NOT NULL,
counted_by INT NOT NULL,
CONSTRAINT pk_stock_counts PRIMARY KEY (stock_count_id),
CONSTRAINT fk_stock_counts_stock FOREIGN KEY (warehouse_id, product_id)
REFERENCES warehouse_stock (warehouse_id, product_id),
CONSTRAINT fk_stock_counts_employee FOREIGN KEY (counted_by)
REFERENCES employees (employee_id),
CONSTRAINT ck_stock_counts_quantity CHECK (counted_quantity >= 0)
);
CREATE INDEX ix_stock_counts_product_date ON stock_counts (product_id, counted_on);Think about it
Think about it
A colleague argues that constraints belong in the application, not the database: the application already validates input, the database duplicates the check, and a constraint violation surfaces as an unfriendly error.
Where is the argument right, and where would you push back?
Show solution
The argument is right about the error. A CHECK violation reaching a user as a stack trace is a poor experience, and validating in the application is what produces a message naming the field and explaining what is wrong. That is a genuine reason to validate in the application, and it is not in dispute.
Where it fails is the assumption that the application is the only way in. Data arrives from migration scripts, a support engineer fixing a row by hand, a bulk import, a second service, and next year's rewrite in another language. Every one of those bypasses the application's validation, and the database is the one place all of them pass through.
It also fails on the difference between a message and a guarantee. Application validation tells a user what went wrong. A constraint makes an invalid row impossible. Reports and downstream systems depend on the second kind of statement, and no amount of careful application code can provide it.
The position to argue for is both, with different jobs: validate in the application for the message, constrain in the database for the guarantee. The duplication is real and it is cheap — a CHECK is one line and costs a fraction of the write.
One concession worth making explicitly: constraints that encode rules likely to change frequently are a poor fit, because changing them means a migration. A permitted status list is stable enough to constrain. A discount threshold that marketing revises monthly belongs in data, not in a CHECK.
Saved in this browser only.