ADR-0021: Table naming convention — module-owned vs cross-cutting registries

Status

Accepted

Tags

database, naming, conventions, ddd, modules

Decision

Table names follow one of four patterns, chosen by ownership and scope — not by tenancy:
PatternWhenExamples
<module>_<entity>A specific module owns the tableai_configs, ai_usage, ff_tasks, leave_balances, audit_log
org_<entity>Cross-cutting per-org registry — any module can write rows, no single ownerorg_feature_flags, org_module_configs
<entity> (no prefix)Globally shared, not per-orgusers, ai_models, ai_plans, ai_config_defaults
admin_<entity>Platform-admin scopeadmin_feature_flags, admin_audit_logs, admin_email_templates
The org_ prefix is not a tenancy marker. Most tables are per-org and that fact is implicit in the organization_id column on every row, not encoded in the table name. The prefix is reserved for registries with no single owning module.

Why

The codebase already has ~25 per-org tables without the prefix (ai_configs, ff_tasks, leave_*, customers, orders, …) and only two with it (org_feature_flags, org_module_configs). Without writing the rule down, the next author working on a fieldforce-adjacent feature looked at org_feature_flags and named their AI scaffolding org_ai_configs — a mis-prefix that ended up never being adopted by the live AI module (which uses ai_configs). The dead scaffold then conflicted with the phase-3 design and cost a grilling session to untangle. Documenting the rule prevents recurrence. Rejected alternatives:
  • Always prefix per-org tables with org_. Would require renaming ~25 tables, breaking every existing query. Rejected — the convention horse already left the barn.
  • No prefixes at all (rely on docs). Loses the useful signal that org_feature_flags is a generic registry, not module-owned. Rejected.
  • Tenancy prefix (t_ for tenant tables). Doesn’t match any existing convention; would be a third pattern to learn. Rejected.

How it works

When adding a new table, ask the questions in this order:
  1. Is it per-org? If no → <entity> (e.g. ai_models) or admin_<entity> for admin-scope.
  2. If per-org, is there a single owning module? If yes → <module>_<entity> (e.g. ai_configs). If no — the table is a cross-cutting registry consumed by multiple modules — → org_<entity> (e.g. org_feature_flags).
“Cross-cutting registry” has a narrow meaning: the table’s SCHEMA is generic (typically org_id + key + a value column) and rows for different consumers coexist in the same table. Today: org_feature_flags, org_module_configs. New entries are rare — most module work creates module-owned tables.

Known limitations

  • The convention does not encode whether a table is per-org versus shared. Readers infer this from columns (organization_id present or not).
  • audit_log and audit_trail are per-org but use neither prefix — they predate this convention and are not worth renaming.
  • “Owning module” is sometimes ambiguous for cross-module entities (e.g. users is consumed by every module). When in doubt, prefer no prefix and treat the table as global.

Rules for agents

  • New per-org tables owned by one module MUST use the <module>_<entity> pattern.
  • New tables MUST NOT use the org_ prefix unless they are generic registries (org_id + key + JSONB or similar shape).
  • When extending an existing table, do not rename it to fit this convention.
  • When renaming is genuinely necessary (e.g. removing a mis-prefixed scaffold), do it as part of a migration that also drops the old table, never in parallel.

Bad pattern (do not generate)

-- Mis-prefixing a module-owned table:
CREATE TABLE org_ai_configs (...);     -- wrong: AI module owns this
CREATE TABLE org_ff_tasks  (...);      -- wrong: fieldforce module owns this
CREATE TABLE org_leave_requests (...); -- wrong: leave module owns this

Good pattern

-- Module owns it → module prefix:
CREATE TABLE ai_configs        (... organization_id uuid ...);
CREATE TABLE ai_usage          (... organization_id uuid ...);
CREATE TABLE ff_tasks          (... organization_id uuid ...);

-- Cross-cutting registry → org_ prefix:
CREATE TABLE org_feature_flags (... org_id uuid, key text, enabled bool ...);
CREATE TABLE org_module_configs(... org_id uuid, module_name text, config jsonb ...);

-- Global / shared → no prefix:
CREATE TABLE ai_plans          (... id uuid, code text, ...);
CREATE TABLE ai_models         (... id uuid, provider text, model_id text, ...);
CREATE TABLE ai_config_defaults(... id uuid, ... singleton ...);

-- Platform-admin scope:
CREATE TABLE admin_feature_flags(... key text, is_enabled bool, ...);