Design relational schemas using normalization forms, strategic denormalization, polymorphic associations, soft delete with audit trails, and temporal data patterns. Covers PostgreSQL and the ORM layer (Prisma / TypeORM).
The canonical home for this skill is data-modeling-patterns in JaviMontano/mao-sovereign-architect
"Data models encode business rules — get them wrong and everything downstream suffers from the same misunderstanding."
Five-step procedure to design production-quality relational schemas: normalization analysis, strategic denormalization for performance, polymorphic association patterns, soft delete with full audit trail, and temporal/historical data modeling.
| Form | Rule | Violation Example | Fix |
|---|---|---|---|
| 1NF | No repeating groups; atomic values | phone1, phone2, phone3 columns | phones table with FK |
| 2NF | No partial dependency (all non-key attrs depend on full PK) | order_items.product_name depending only on product_id | Move to products table |
| 3NF | No transitive dependency | orders.customer_city depending on customer_id | Move to customers table |
| BCNF | Every determinant is a candidate key | Rare in practice | Decompose relation |
-- Normalized base tables
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- Soft delete
);
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Normalized join table with payload
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, organization_id)
);Denormalization is a deliberate performance optimization — not laziness.
-- Instead of COUNT(*) on every request, maintain a counter column
ALTER TABLE posts ADD COLUMN comments_count INT NOT NULL DEFAULT 0;
-- Trigger to maintain count
CREATE OR REPLACE FUNCTION update_post_comments_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE posts SET comments_count = comments_count + 1 WHERE id = NEW.post_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE posts SET comments_count = comments_count - 1 WHERE id = OLD.post_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_comments_count
AFTER INSERT OR DELETE ON comments
FOR EACH ROW EXECUTE FUNCTION update_post_comments_count();-- When attributes vary by type and are rarely filtered
ALTER TABLE products ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}';
-- Query with GIN index
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);
-- Usage: WHERE metadata @> '{"color": "red"}'
-- Usage: WHERE metadata->>'sku' = 'ABC-123'-- Each type has its own table; no polymorphism at DB level
CREATE TABLE comment_on_post (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
body TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users(id)
);
CREATE TABLE comment_on_product (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
body TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users(id)
);-- commentable pattern
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
body TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
commentable_type TEXT NOT NULL, -- 'post', 'product', 'review'
commentable_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_comments_polymorphic ON comments(commentable_type, commentable_id);
-- Note: No FK constraint — must enforce referential integrity in applicationCREATE TABLE attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url TEXT NOT NULL,
-- Exactly one must be non-null (enforce with CHECK)
post_id UUID REFERENCES posts(id),
comment_id UUID REFERENCES comments(id),
CONSTRAINT exactly_one_parent CHECK (
(post_id IS NOT NULL)::INT + (comment_id IS NOT NULL)::INT = 1
)
);-- All queries must filter deleted_at IS NULL
-- Use RLS or default scope in ORM to enforce
-- PostgreSQL Row Level Security
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_soft_delete ON users
USING (deleted_at IS NULL);
-- Prisma soft delete middleware
const softDeleteMiddleware: Prisma.Middleware = async (params, next) => {
if (params.action === 'delete') {
params.action = 'update';
params.args.data = { deletedAt: new Date() };
}
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args.where = { ...params.args.where, deletedAt: null };
}
return next(params);
};CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
operation TEXT NOT NULL, -- INSERT, UPDATE, DELETE
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
old_values JSONB,
new_values JSONB
);
CREATE INDEX idx_audit_log_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log(changed_at DESC);
-- Generic audit trigger
CREATE OR REPLACE FUNCTION log_audit_event() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values)
VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
TG_OP,
CASE WHEN TG_OP != 'INSERT' THEN to_jsonb(OLD) END,
CASE WHEN TG_OP != 'DELETE' THEN to_jsonb(NEW) END
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;-- Valid time: when fact was true in the real world
-- Transaction time: when fact was recorded in the database
CREATE TABLE product_prices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id),
price_cents INT NOT NULL,
valid_from TIMESTAMPTZ NOT NULL,
valid_to TIMESTAMPTZ, -- NULL means current
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
EXCLUDE USING GIST (
product_id WITH =,
tstzrange(valid_from, valid_to, '[)') WITH &&
) -- No overlapping periods for same product
);updated_at maintained — Either via trigger or ORM hook on all mutable tables.NOT NULL FKs enforce data integrity.(entity_id, attribute_name, value TEXT) pattern destroys type safety, makes queries unreadable, and performs poorly; use JSONB instead.user_ids = "1,2,3" violates 1NF; cannot be joined, indexed, or enforced with FK constraints.INT surrogate keys exposed in URLs — Sequential IDs enable enumeration attacks; use UUIDs for any user-visible identifiers.deleted_at IS NOT NULL leak into API responses when developers forget the filter clause.b56591e
Canonical home
since Aug 28, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.