53 lines
2.7 KiB
Markdown
53 lines
2.7 KiB
Markdown
# Schema Design — Derived Rules
|
|
|
|
> Derives from `domains/data/first-principles.md` P1 (Truth), P3 (Invariants in Schema), P7 (Type Fidelity).
|
|
|
|
## The Schema Reflects the Domain (P1 Truth)
|
|
|
|
- A `users` table has columns that are attributes of a user, not attributes of the application.
|
|
- If a column is named `is_active_for_feature_X`, the schema is lying. The domain does not have "feature X."
|
|
- Normalize until it hurts, then denormalize only with evidence (P10 Performance Awareness).
|
|
|
|
## Invariants in the Schema (P3)
|
|
|
|
- NOT NULL where the value is required. UNIQUE where the value is unique.
|
|
- CHECK constraints for range/domain: `age >= 0`, `status IN ('draft', 'published')`.
|
|
- FOREIGN KEY for relationships. The database enforces; the application defends.
|
|
- A constraint in the application but not the schema is a constraint that can be bypassed.
|
|
|
|
## Types (P7 Type Fidelity)
|
|
|
|
- `UUID` for IDs, not `VARCHAR`. `UUID` is a type; `VARCHAR(36)` is a string that looks like a UUID.
|
|
- `TIMESTAMPTZ` for timestamps, not `VARCHAR` or `INTEGER`. Timezone-aware by default.
|
|
- `ENUM` for finite domains, `VARCHAR` with CHECK for evolving domains.
|
|
- `JSONB` for unstructured/semi-structured; not for data that should be a column.
|
|
- `DECIMAL`/`NUMERIC` for money, never `FLOAT`. Floating point is for measurements, not money.
|
|
|
|
## Naming (P6 Naming Consistency)
|
|
|
|
- snake_case for tables and columns (PostgreSQL convention): `user_accounts`, `created_at`.
|
|
- Singular table names (`user` not `users`) OR plural (`users` not `user`) — pick one, be consistent.
|
|
- Foreign keys: `<singular_table>_id` (`user_id`), not `uid` or `user`.
|
|
- Junction tables: alphabetical (`order_products`, not `products_orders`).
|
|
|
|
## Avoid (P2 Normalization Discipline)
|
|
|
|
- Computed columns that duplicate derivable data. Use a view or compute on read.
|
|
- `created_by_name` (denormalized) when `created_by_id` + JOIN suffices. Denormalize only with evidence.
|
|
- Soft-delete columns (`is_deleted`) without a corresponding constraint/behavior. Soft delete is a lifecycle decision (P8).
|
|
|
|
## Soft Delete vs Hard Delete (P8 Lifecycle Awareness)
|
|
|
|
- Soft delete (`deleted_at TIMESTAMP`) preserves auditability but complicates every query.
|
|
- Hard delete loses history. Choose based on the domain's legal/audit requirements.
|
|
- If soft delete: every query filters `WHERE deleted_at IS NULL` by default. A missing filter is a bug.
|
|
|
|
## What Violates Schema Design
|
|
|
|
| Violation | Principle |
|
|
|-----------|-----------|
|
|
| `VARCHAR` for a UUID | P7 Type Fidelity |
|
|
| No FOREIGN KEY on a relationship | P3, P9 Referential Integrity |
|
|
| `FLOAT` for money | P7, P1 Truth |
|
|
| `is_deleted` without consistent filtering | P8 Lifecycle |
|
|
| A column named after a feature, not a domain concept | P1 Truth | |