Database design patterns and anti-patterns

Maria Garcia Feb 2026
2 tabs
-- Pattern: Single Table Inheritance (STI)
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  type VARCHAR(50) NOT NULL, -- 'admin', 'customer', 'vendor'
  username VARCHAR(100) UNIQUE NOT NULL,
  email VARCHAR(100) UNIQUE NOT NULL,
  -- Admin-specific
  permission_level INT,
  -- Customer-specific
  loyalty_points INT,
  -- Vendor-specific
  company_name VARCHAR(200),
  tax_id VARCHAR(50),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  CHECK (
    (type = 'admin' AND permission_level IS NOT NULL) OR
    (type = 'customer' AND loyalty_points IS NOT NULL) OR
    (type = 'vendor' AND company_name IS NOT NULL)
  )
);

-- Query specific type
SELECT * FROM users WHERE type = 'admin';

-- Pattern: Class Table Inheritance (CTI)
CREATE TABLE users_base (
  id SERIAL PRIMARY KEY,
  username VARCHAR(100) UNIQUE NOT NULL,
  email VARCHAR(100) UNIQUE NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE admins (
  user_id INT PRIMARY KEY REFERENCES users_base(id) ON DELETE CASCADE,
  permission_level INT NOT NULL
);

CREATE TABLE customers (
  user_id INT PRIMARY KEY REFERENCES users_base(id) ON DELETE CASCADE,
  loyalty_points INT DEFAULT 0
);

CREATE TABLE vendors (
  user_id INT PRIMARY KEY REFERENCES users_base(id) ON DELETE CASCADE,
  company_name VARCHAR(200) NOT NULL,
  tax_id VARCHAR(50)
);

-- Query with join
SELECT u.*, c.loyalty_points
FROM users_base u
JOIN customers c ON u.id = c.user_id
WHERE u.email = 'customer@example.com';

-- Pattern: Soft deletes
ALTER TABLE products ADD COLUMN deleted_at TIMESTAMP;
CREATE INDEX idx_products_deleted_at ON products(deleted_at) WHERE deleted_at IS NULL;

-- Delete (soft)
UPDATE products SET deleted_at = CURRENT_TIMESTAMP WHERE id = 123;

-- Query active records
SELECT * FROM products WHERE deleted_at IS NULL;

-- Create view for convenience
CREATE VIEW active_products AS
SELECT * FROM products WHERE deleted_at IS NULL;

SELECT * FROM active_products;

-- Pattern: Audit trail
CREATE TABLE products_audit (
  audit_id SERIAL PRIMARY KEY,
  product_id INT NOT NULL,
  operation VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE
  old_data JSONB,
  new_data JSONB,
  changed_by VARCHAR(100),
  changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE OR REPLACE FUNCTION audit_products()
RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    INSERT INTO products_audit (product_id, operation, new_data, changed_by)
    VALUES (NEW.id, 'INSERT', row_to_json(NEW)::jsonb, current_user);
  ELSIF TG_OP = 'UPDATE' THEN
    INSERT INTO products_audit (product_id, operation, old_data, new_data, changed_by)
    VALUES (NEW.id, 'UPDATE', row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb, current_user);
  ELSIF TG_OP = 'DELETE' THEN
    INSERT INTO products_audit (product_id, operation, old_data, changed_by)
    VALUES (OLD.id, 'DELETE', row_to_json(OLD)::jsonb, current_user);
  END IF;
  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER products_audit_trigger
AFTER INSERT OR UPDATE OR DELETE ON products
FOR EACH ROW EXECUTE FUNCTION audit_products();

-- View audit history
SELECT
  audit_id,
  product_id,
  operation,
  old_data->>'name' AS old_name,
  new_data->>'name' AS new_name,
  changed_by,
  changed_at
FROM products_audit
WHERE product_id = 123
ORDER BY changed_at DESC;

-- Pattern: Optimistic locking with version
CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  title VARCHAR(200),
  content TEXT,
  version INT DEFAULT 1,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Update with version check
UPDATE documents
SET
  content = 'New content',
  version = version + 1,
  updated_at = CURRENT_TIMESTAMP
WHERE id = 123 AND version = 5;
-- Returns 0 rows if version changed (conflict)

-- Pattern: Materialized path for hierarchies
CREATE TABLE categories_path (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100),
  path VARCHAR(500), -- '/1/3/7/'
  depth INT
);

CREATE INDEX idx_categories_path ON categories_path(path);

-- Find all descendants
SELECT * FROM categories_path
WHERE path LIKE '/1/3/%';

-- Find all ancestors
-- For node '/1/3/7/15/', extract ancestors
WITH RECURSIVE ancestors AS (
  SELECT 1 AS level, '/1/' AS ancestor_path
  UNION ALL
  SELECT level + 1, ancestor_path || '3/'
  FROM ancestors WHERE level < 3
)
SELECT c.*
FROM ancestors a
JOIN categories_path c ON c.path = a.ancestor_path;
2 files · sql Explain with highlit

Database design patterns solve recurring problems. I use the Repository pattern to abstract data access. Active Record maps objects to tables. Unit of Work tracks changes for batch commits. Identity Map caches loaded entities. The Specification pattern encapsulates query logic. Avoid anti-patterns: EAV (Entity-Attribute-Value) creates query complexity. Polymorphic associations without constraints risk orphans. Massive denormalization causes update anomalies. God tables accumulate too many columns. Improper indexing degrades performance. Understanding patterns prevents technical debt, improves maintainability. Essential for scalable, clean database architecture.