Database Conventions

Database Conventions

General

  • PostgreSQL 16+
  • One schema prefix per module (e.g., people_, groups_, events_)
  • UUIDs (v7) for all primary keys
  • created_at and updated_at timestamps on every table
  • Soft deletes only when business requires it (prefer hard delete)

Naming

  • Tables: {module}_{entity} in snake_case plural (people_members, groups_groups)
  • Columns: snake_case (first_name, created_at)
  • Foreign keys: {referenced_table_singular}_id (group_id, member_id)
  • Indexes: idx_{table}_{columns} (idx_people_members_email)
  • Unique constraints: uniq_{table}_{columns}

Types

  • IDs: uuid (UUID v7 for time-ordering)
  • Timestamps: timestamptz (always with timezone, stored as UTC)
  • Timezones: varchar(64) for IANA timezone identifiers (e.g., Europe/Zurich)
  • Money: bigint (store in cents/smallest unit)
  • Enums: varchar with CHECK constraint (not PostgreSQL enum type)
  • JSON: jsonb for flexible structured data
  • Hierarchies: ltree for tree structures (org chart, group hierarchy)

Multi-tenancy

  • Every table has a tenant_id column
  • Composite indexes always include tenant_id first
  • Row-level security (RLS) as defense-in-depth
  • Application-level tenant scoping is the primary mechanism

Migrations

  • Doctrine Migrations for schema changes
  • One migration per logical change
  • Migrations must be reversible (include down())
  • Never modify a migration that’s been deployed
  • Test migrations on a clean database

Indexes

  • Always index foreign keys
  • Always index tenant_id (usually composite with other columns)
  • Use partial indexes where appropriate (WHERE deleted_at IS NULL)
  • Use GIN indexes for JSONB columns that are queried
  • Profile before adding indexes — don’t over-index

Cross-module

  • No foreign keys between modules
  • Use UUID references (eventual consistency)
  • Cross-module data access via contract interfaces or events