plugins/backend-toolkit/skills/schema-design/SKILL.md
Design a relational schema — normalize to 3NF then denormalize with justification, choose the right Postgres index type per data shape, enforce constraints at the DB. Use when modeling a new domain, when queries are slow, or before a migration. Not for diagnosing slow queries (use query-optimization) or shipping the change without downtime (use migration-strategy).
npx skillsauth add jaykim88/claude-ai-engineering schema-designInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Model the domain so the database — not application code — enforces integrity, and so reads are fast by design via the right index type for each access pattern.
Universal — normalization (3NF then justified denormalization), constraint-at-the-DB discipline, and index-type-by-data-shape apply to any relational DB; index type names and DDL differ.
Normalize to 3NF first
Enforce integrity at the database
NOT NULL, UNIQUE, CHECK, foreign keys with explicit ON DELETEdata-validation) is the first, not the onlyChoose index type by data shape
Index the access patterns, not the columns
INCLUDE) for index-only scans on hot queriesWHERE status = 'active') shrink the index and speed the dominant predicate — common 10x win on tables with a small active subsetDesign keys deliberately
uuidv7(); on earlier versions generate it app-side (no core/uuid-ossp function exists — uuid-ossp only provides v1/v3/v4/v5)5b. Soft-delete vs hard-delete — decide deliberately
deleted_at timestamp): audit / undo / cross-reference survival. Cost: every query must filter WHERE deleted_at IS NULL (a single forgotten filter leaks deleted data — automate via RLS or a view), and unique constraints need partial indexes to allow re-creation after deletionEXPLAIN ANALYZE; if Seq Scan on a large table or bad row estimates → adjust index and re-run (hand off to query-optimization)| ❌ Anti-pattern | ✅ Correct |
|---|---|
| Integrity enforced only in app code | DB constraints (NOT NULL / FK / CHECK / UNIQUE) |
| B-tree index on a JSONB containment query | GIN index |
| Indexing every column "just in case" | Index measured access patterns; each index costs writes |
| Premature denormalization | 3NF first; denormalize with a documented read justification |
| Random UUID v4 PK on a 100M-row table | UUID v7 / bigint for time-ordered locality |
| FK column without an index (slow cascade / lock storm) | Index every FK child column |
| Index covering both active and archived rows | Partial index WHERE status = 'active' |
| Soft-delete with one missing WHERE deleted_at IS NULL filter (data leak) | Enforce via view or RLS, not application-by-application filtering |
| Tier | Examples | Action SLA | |---|---|---| | Critical | Missing FK allowing orphaned rows in a money/ownership table; no unique constraint on a natural key causing duplicates | Fix immediately | | Major | Wrong index type on a hot query (Seq Scan); integrity enforced only in app | Fix this sprint | | Minor | Over-indexing; suboptimal composite column order | Schedule within 2 sprints |
feat(schema): model <domain> / perf(schema): add GIN index for <jsonb query>@@index, @@unique, @relation(onDelete:), @db.* types@default(dbgenerated("uuidv7()")); pre-18 generate app-side (e.g., uuidv7 npm package) — uuid_generate_v7() does NOT exist in core or uuid-osspEXPLAIN ANALYZE in psql / Supabase dashboardIndex(..., postgresql_using='gin')query-optimization — indexes are validated against actual query plans theremigration-strategy — schema changes ship via safe migrationstransaction-management — constraints + isolation enforce integrity togetherdevelopment
Design webhooks correctly on both sides — sending (HMAC signing, retries with backoff, at-least-once) and receiving (verify signature on raw body, enqueue + 200 fast, dedupe on event id). Use when adding webhook delivery or consuming a provider's webhooks. Not for internal service-to-service events (use async-messaging) or general outbound-call retry policy (use resilience-patterns).
testing
Use transactions and isolation levels correctly — keep them short, no network calls inside, explicit isolation, retry on serialization conflicts, and choose optimistic vs pessimistic locking. Use when a write spans multiple tables, when concurrent updates corrupt data, or when designing money/inventory flows. Not for cross-service event delivery (use async-messaging Outbox) or schema-level constraints (use schema-design).
development
Backend testing pyramid — unit for pure logic, integration against a real DB (Testcontainers), and consumer-driven contract testing (Pact) for service boundaries. Use before a feature, after a bug fix, or when services break each other on deploy. Not for load testing (use performance-profiling) or security testing (use backend-security-audit).
development
Apply reliability primitives — capped exponential backoff with jitter, circuit breakers, timeouts, and idempotency keys — to every outbound call and mutating endpoint. Use when integrating an external service, when retries cause duplicate effects, or before shipping a payment/order flow. Not for job-runner retry config specifically (use background-jobs) or webhook-delivery specifics (use webhook-design, which reuses these primitives).