Database Design
Overview
Define the project's schema conventions and migration safety policy before migrations land in production. Outputs .forge/database-design.md (naming, FK rules, audit columns, soft-delete policy, index strategy, partition strategy, RLS/tenant-scoping rules) and .forge/migrations-policy.md (reversibility, idempotency, locking-aware patterns, the query-review checklist). Consumed by architecture-and-contracts and incremental-implementation.
When to Use
- A new project starts and the schema is being designed
- A migration is being written that touches a table >1M rows
- An ORM is generating N+1 queries in a hot path
- An audit reveals tables with no FK constraints or missing indexes
- The team is debating soft delete vs hard delete and there's no policy
- A production query is timing out and there's no EXPLAIN process
When NOT to Use
- Adding a column to a tiny dev-only table where conventions already apply
- Schema-less stores (Mongo, DynamoDB) — see
architecture-and-contracts data-model section instead
- A migration on an existing project that has documented conventions and the change is local
Common Rationalizations
| Thought |
Reality |
| "We can add indexes later" |
Missing indexes in production cause outages, not slowdowns. A full table scan on a 50M-row table locks the DB. |
| "Soft delete is always better" |
Soft delete without a cleanup job becomes data hoarding. GDPR forces real deletion eventually. |
| "ORMs handle it" |
ORMs generate N+1 queries by default. Lazy loading + a loop = production incident. |
| "One big migration is fine" |
Irreversible migrations are deployment landmines. Forward-only with a long expand-contract window is the safe path. |
| "We don't need FK constraints, the app enforces it" |
The app has bugs. The DB doesn't. FK constraints are the last line of defense against orphan rows. |
| "Let's just denormalize for speed" |
Denormalization without a sync job creates two sources of truth that diverge. |
Red Flags
ALTER TABLE large_table ADD COLUMN ... NOT NULL with no locking review
- Foreign key columns without indexes
- A migration that drops or renames a column without a deprecation window
- Soft delete columns (
deleted_at) with no cleanup or RLS policy
- ORM
findAll() followed by .map(item => item.related) (N+1)
- A query that scans rows without an EXPLAIN run
- No
created_at / updated_at audit columns
- Multi-tenant tables without RLS or
tenant_id indexes
Core Process
Step 1: Define naming conventions
Write these to .forge/database-design.md:
- Tables:
snake_case, plural (users, task_assignments)
- Columns:
snake_case, no abbreviations (created_at not cr_at)
- Primary keys:
id (UUIDv7 preferred — sortable, no hot shards)
- Foreign keys:
<table_singular>_id (user_id, task_id)
- Indexes:
idx_<table>_<columns>
- Constraints:
chk_<table>_<rule>, unq_<table>_<columns>
- Audit columns required on every business table:
created_at, updated_at (timestamptz, server-set)
Step 2: Audit existing schema (if applicable)
Run a check: every FK has an index? every business table has audit columns? every soft-delete table has cleanup? Record violations in .forge/database-design.md with an owner and a deadline.
Step 3: Set migration guardrails
In .forge/migrations-policy.md:
- Reversibility: every migration has an explicit
down. Forward-only migrations require an ADR.
- Idempotency:
CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS.
- Locking-aware:
CREATE INDEX CONCURRENTLY on Postgres. Online schema change (gh-ost, pt-osc) for MySQL on large tables. Never ALTER TABLE with ACCESS EXCLUSIVE LOCK during business hours.
- Expand-contract pattern: add new column → backfill → switch reads → drop old column over multiple deploys.
- Backup before destructive: any
DROP COLUMN, DROP TABLE, TRUNCATE, or destructive UPDATE requires a snapshot reference in the PR.
Step 4: Write the query-review checklist
Append to .forge/database-design.md:
- Every hot-path query has been EXPLAINed (Postgres) or
EXPLAIN ANALYZEd
- No sequential scan on a table >100k rows
- No N+1 — verify with query logs in a test that simulates the loop
- Index used for every JOIN and WHERE condition
- LIMIT used on any user-facing list query
- Pagination uses cursor (not OFFSET) for large tables
Step 5: Identify partition candidates
Tables likely to exceed 100M rows (events, logs, audit, time-series): plan partitioning before they grow. Document the partition key (usually created_at range or tenant_id hash) and the retention/cleanup job.
Step 6: Document RLS / tenant-scoping rules
For multi-tenant systems:
- Every business table has
tenant_id and a Row-Level Security policy
- Application-side defense (every query filters by
tenant_id) + DB-side defense (RLS) — both, not either-or
- The contract from
architecture-and-contracts MUST specify which tables are tenant-scoped
Step 7: Headers
Prepend a forge:meta header on both .forge/database-design.md and .forge/migrations-policy.md (co-output — both share the same depends_on + generated_from): generated_by: database-design, generated_at: <ISO 8601 UTC with Z>, depends_on: [.forge/architecture.md] — paths only, never hashes, generated_from: {.forge/architecture.md: <upstream content_hash AT generation time>}, content_hash: <sha256 first 8 of THIS file's body> (each file computes its own). See forge-dependency-graph.
Verification
1---2name: database-design3description: Use when designing a new schema, writing a migration, adding or auditing indexes, reviewing query performance, choosing between hard delete and soft delete, or establishing database conventions for a project.4---56# Database Design78## Overview910Define the project's schema conventions and migration safety policy *before* migrations land in production. Outputs `.forge/database-design.md` (naming, FK rules, audit columns, soft-delete policy, index strategy, partition strategy, RLS/tenant-scoping rules) and `.forge/migrations-policy.md` (reversibility, idempotency, locking-aware patterns, the query-review checklist). Consumed by `architecture-and-contracts` and `incremental-implementation`.1112## When to Use1314- A new project starts and the schema is being designed15- A migration is being written that touches a table >1M rows16- An ORM is generating N+1 queries in a hot path17- An audit reveals tables with no FK constraints or missing indexes18- The team is debating soft delete vs hard delete and there's no policy19- A production query is timing out and there's no EXPLAIN process2021## When NOT to Use2223- Adding a column to a tiny dev-only table where conventions already apply24- Schema-less stores (Mongo, DynamoDB) — see `architecture-and-contracts` data-model section instead25- A migration on an existing project that has documented conventions and the change is local2627## Common Rationalizations2829| Thought | Reality |30|---------|---------|31| "We can add indexes later" | Missing indexes in production cause outages, not slowdowns. A full table scan on a 50M-row table locks the DB. |32| "Soft delete is always better" | Soft delete without a cleanup job becomes data hoarding. GDPR forces real deletion eventually. |33| "ORMs handle it" | ORMs generate N+1 queries by default. Lazy loading + a loop = production incident. |34| "One big migration is fine" | Irreversible migrations are deployment landmines. Forward-only with a long expand-contract window is the safe path. |35| "We don't need FK constraints, the app enforces it" | The app has bugs. The DB doesn't. FK constraints are the last line of defense against orphan rows. |36| "Let's just denormalize for speed" | Denormalization without a sync job creates two sources of truth that diverge. |3738## Red Flags3940- `ALTER TABLE large_table ADD COLUMN ... NOT NULL` with no locking review41- Foreign key columns without indexes42- A migration that drops or renames a column without a deprecation window43- Soft delete columns (`deleted_at`) with no cleanup or RLS policy44- ORM `findAll()` followed by `.map(item => item.related)` (N+1)45- A query that scans rows without an EXPLAIN run46- No `created_at` / `updated_at` audit columns47- Multi-tenant tables without RLS or `tenant_id` indexes4849## Core Process5051### Step 1: Define naming conventions5253Write these to `.forge/database-design.md`:54- Tables: `snake_case`, plural (`users`, `task_assignments`)55- Columns: `snake_case`, no abbreviations (`created_at` not `cr_at`)56- Primary keys: `id` (UUIDv7 preferred — sortable, no hot shards)57- Foreign keys: `<table_singular>_id` (`user_id`, `task_id`)58- Indexes: `idx_<table>_<columns>`59- Constraints: `chk_<table>_<rule>`, `unq_<table>_<columns>`60- Audit columns required on every business table: `created_at`, `updated_at` (timestamptz, server-set)6162### Step 2: Audit existing schema (if applicable)6364Run a check: every FK has an index? every business table has audit columns? every soft-delete table has cleanup? Record violations in `.forge/database-design.md` with an owner and a deadline.6566### Step 3: Set migration guardrails6768In `.forge/migrations-policy.md`:69- **Reversibility:** every migration has an explicit `down`. Forward-only migrations require an ADR.70- **Idempotency:** `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`.71- **Locking-aware:** `CREATE INDEX CONCURRENTLY` on Postgres. Online schema change (gh-ost, pt-osc) for MySQL on large tables. Never `ALTER TABLE` with `ACCESS EXCLUSIVE LOCK` during business hours.72- **Expand-contract pattern:** add new column → backfill → switch reads → drop old column over multiple deploys.73- **Backup before destructive:** any `DROP COLUMN`, `DROP TABLE`, `TRUNCATE`, or destructive `UPDATE` requires a snapshot reference in the PR.7475### Step 4: Write the query-review checklist7677Append to `.forge/database-design.md`:78- Every hot-path query has been EXPLAINed (Postgres) or `EXPLAIN ANALYZE`d79- No sequential scan on a table >100k rows80- No N+1 — verify with query logs in a test that simulates the loop81- Index used for every JOIN and WHERE condition82- LIMIT used on any user-facing list query83- Pagination uses cursor (not OFFSET) for large tables8485### Step 5: Identify partition candidates8687Tables likely to exceed 100M rows (events, logs, audit, time-series): plan partitioning *before* they grow. Document the partition key (usually `created_at` range or `tenant_id` hash) and the retention/cleanup job.8889### Step 6: Document RLS / tenant-scoping rules9091For multi-tenant systems:92- Every business table has `tenant_id` and a Row-Level Security policy93- Application-side defense (every query filters by `tenant_id`) + DB-side defense (RLS) — both, not either-or94- The contract from `architecture-and-contracts` MUST specify which tables are tenant-scoped9596### Step 7: Headers9798Prepend a `forge:meta` header on both `.forge/database-design.md` and `.forge/migrations-policy.md` (co-output — both share the same `depends_on` + `generated_from`): `generated_by: database-design`, `generated_at: <ISO 8601 UTC with Z>`, `depends_on: [.forge/architecture.md]` — paths only, never hashes, `generated_from: {.forge/architecture.md: <upstream content_hash AT generation time>}`, `content_hash: <sha256 first 8 of THIS file's body>` (each file computes its own). See [forge-dependency-graph](../../references/forge-dependency-graph.md).99100## Verification101102- [ ] `.forge/database-design.md` written with naming conventions, soft-delete policy, partition strategy103- [ ] `.forge/migrations-policy.md` written with reversibility, idempotency, locking-aware rules104- [ ] Every migration in the codebase is idempotent (`IF NOT EXISTS` or equivalent)105- [ ] Every FK has an index (verified via schema query)106- [ ] Every business table has `created_at` and `updated_at`107- [ ] Every hot-path query has an EXPLAIN attached to the PR (or linked in `.forge/database-design.md`)108- [ ] No migration drops user-visible data without an ADR109- [ ] Multi-tenant tables have RLS policies and `tenant_id` indexes110- [ ] N+1 queries are gone from the codebase (verified by query-count test in CI)