Day 25 of 100·System Design Day·540 words

Relational database design — your schemas as the case study

Normalization, keys, and constraint-driven integrity, graded on the two schemas you own.

Concept → your projects

The craft of relational design: entities → relationships → constraints such that invalid states are unrepresentable.

  • Normalization: 3NF — non-key attributes depend on the key. Denormalize deliberately (read-path copies with defined refresh), never accidentally.
  • Primary/foreign keys: UUID vs bigserial (Day 41), FK actions encode lifecycle (CASCADE / SET NULL / RESTRICT).
  • Join tables for M:N, with composite PKs that are the uniqueness constraint.
  • Enums for closed vocabularies (zoth's DB enums), TEXT for evolving ones (traverse).
  • Indexes follow queries, not guesses (Day 27–29).

Zoth's 11 tables — the design logic

tenants ──< users >── groups (via user_groups PK(user_id,group_id))
   │         │ self-FK manager_id (ON DELETE SET NULL)
   │         │
   └──< integrations ──< resources (external_id, sensitivity)
   ├──< policies (priority, match/access/approval JSONB)
   ├──< access_requests >── approvals
   ├──< audit_logs (prev_hash, hash — append-only)
   ├──< api_keys (key_hash UNIQUE)
   ├──< notifications
   ├──< policy_documents (embedding vector(768))
   └──< outbox_events / processed_events

Design decisions to narrate:

  1. Tenant as the root of every FK chain — cascading deletes from tenants clean the whole org; every query path carries tenant_id for scoping (Day 40).
  2. resources has NO tenant_id — ownership flows through integrations (INNER JOIN). A deliberate denormalization away: single source for ownership; resources can't exist without an integration. The cost: every resource fetch is a join — indexed and fine at this scale.
  3. access_requests.policy_id ON DELETE SET NULL — historical requests must survive policy deletion; the decision is frozen in the request row, not re-derived. Audit-grade thinking: current state tables + immutable history (audit chain).
  4. JSONB for policy rules (match_conditions, access_rules, approval_config) — semi-structured config that evolved in-place without migrations; validated at the service layer (ParseAccessRules etc.). The tradeoff: the DB can't enforce policy shape — the price of schema flexibility.
  5. Enums: platform_type, sensitivity, access_request_status, approval_decision, decision_source, audit_action — closed security vocabularies.

Traverse's model — the design logic (30+ models, 742 lines)

Highlights worth narrating:

  • Submission/Solve split: Submission = the raw event (every attempt, write-heavy, mutable analytics fields); Solve = the first-acceptance fact (@@unique([userId, problemId]), one per user-problem, holds XP). Event/fact separation — the same distinction as zoth's request/audit.
  • Revision + RevisionAttempt: scheduled reviews vs observed attempts — a prediction/observation pair, which is what makes the calibration harness (Day 72) possible.
  • RingDay snapshots goals (solveGoal/revisionGoal copied per-day): "did they close rings on the 4th" keeps the answer it had on the 4th — temporal denormalization done right.
  • Friendship as double-row adjacency (A→B and B→A): "my friends" is one indexed scan, no OR — a modeling choice that trades write redundancy for read simplicity; symmetry maintained only through upsertFriendshipPair (the one writer).
  • StreakDay @@id([userId, date]) — existence is the fact; the streak walk is a date-contiguity scan.
  • String state machines + CHECK constraints (FriendStreak userId1 < userId2): flexibility where product iterates, rigor where math demands it.

The drill

Model "collaborative document editing": documents, snapshots (append-only), presence (ephemeral — belongs in Redis, not Postgres!), permissions. The takeaway rule your schemas embody: operational data (Postgres), ephemeral state (memory/Redis), immutable history (append-only tables) — three lifetimes, three stores.

Bulletproof your resume

Say these answers aloud before moving on

These are the follow-ups an interviewer will actually ask about your projects.

01

Why do resources have no tenant_id in zoth?

Ownership is derived through integrations — resources are discovered FROM an integration, which belongs to a tenant. Single ownership path means no cross-tenant resource can exist by construction, and deletes cascade from the integration. The cost is a join on every resource fetch (GetResourceByIDAndTenant INNER JOINs integrations), which the query-planner makes cheap. If resources became multi-integration, I'd promote a tenant_id column — the current shape is a fact about the domain: resources exist only within an integration.

02

Why split Submission and Solve in traverse?

Different lifetimes and invariants. Submission is the raw event stream: every attempt, mutable analytics columns, write-heavy, append-mostly. Solve is the fact 'this user first-solved this problem on this date' — unique per (userId, problemId), the XP holder, joined to the revision scheduling chain. The unique constraint on Solve IS the idempotency and the 'was this a new solve' answer — check-then-insert races are impossible. Fact tables answer business questions; event tables answer forensic ones.

03

When is denormalization correct?

When the read-path cost is measured and the refresh contract is explicit. My correct examples: RingDay snapshotting goals (the past must not change when settings do — a correctness denormalization, not a perf one), Friendship double rows (read simplicity with a single enforced writer). My JSONB policies are schema-flexibility, not denormalization. The anti-pattern is copy-for-speed with no invalidation story — every denormalized field needs an owner: the code that keeps it true.