Anti-patterns & worked example
Anti-patterns
| Anti-pattern | Why | Use instead |
|---|---|---|
UPPERCASE SQL KEYWORDS in new code |
Harder to read, drift from convention | select, from, where, create or replace function, ... |
_foo as a local variable |
Reserved for input parameters | __foo (or ___foo if it collides with a return column) |
Foreign key column named just id |
Ambiguous in joins | user_id, tenant_id, <target-pk-name> |
tbl_user, t_user, usr |
Decoration / abbreviation | user_info (suffix if reserved word collision); user only if not reserved |
vw_users, v_users |
Decoration | users |
bool column named active, deleted |
Missing predicate prefix | is_active, is_deleted |
timestamp (no time zone) |
Loses zone information | timestamp with time zone (timestamptz) |
id bigserial primary key |
serial is legacy |
id bigint generated always as identity primary key |
Inline raise exception '...' |
Wording / code drift across callers | perform error.raise_NNNNN(...) |
CHECK (status IN ('a','b','c')) |
Brittle to extend | status_code text not null references const.<concept>_type |
Calling auth.foo() from auth.bar() |
Double permission check, transactional weirdness | Extract shared logic into internal.bar() (or unsecure.bar() if it's auth/authz-sensitive); call from both wrappers |
Ordinary business mutation in unsecure.* |
unsecure is reserved for auth/authz internals only |
Put it in internal.* — reached via a permission-checked wrapper or a trusted job |
select * from user_info (unqualified) |
search_path dependent | select * from auth.user_info |
returns setof record |
Caller must specify column types | returns TABLE(__col1 type, __col2 type, ...) |
ext.ltree in an auth.* parameter |
Client libraries cannot map extension types | Accept text, convert internally with ext.text2ltree(...) |
Re-implementing internal.resolve_cross_tenant_access logic inline |
DRY violation; rules diverge | Call the resolver |
search_x(_filter, _page_size, _page_number, _order_by, ...) positional |
Every new filter or sort option churns the signature and every caller | Two jsonb bags: _search_criteria (filters) + _search_settings (paging/order) |
Interpolating _search_settings->>'order_by' into dynamic SQL |
SQL injection / invalid-column errors | Whitelist the key → column/expression mapping; default on miss |
raise-ing on an unknown _search_criteria key |
Breaks forward compatibility with newer clients | Ignore unknown keys; default missing ones |
enum type for a reference list |
Hard to extend, no metadata | const.<concept>_type table + FK |
| Down-migration script | Forward-only is the convention | Write the next forward script |
Worked example — a complete auth.* mutation
The shape every auth.* function follows:
create or replace function auth.assign_permission(
_created_by text,
_user_id bigint,
_correlation_id text,
_user_group_id integer,
_target_user_id bigint,
_perm_set_code text,
_perm_code text,
_request_context jsonb default null,
_tenant_id integer default 1
)
returns SETOF auth.permission_assignment
language plpgsql
as $$
declare
__assignment auth.permission_assignment;
begin
-- 1. Permission check (always first; raises 32xxx on failure)
perform auth.has_permission(_user_id, _correlation_id, 'permissions.assign_permission', _tenant_id);
-- 2. Delegate to unsecure for the actual work
return query
select * from unsecure.assign_permission(
_created_by, _user_id, _correlation_id,
_user_group_id, _target_user_id, _perm_set_code, _perm_code,
_request_context, _tenant_id
);
end;
$$;
And the matching unsecure.* worker:
create or replace function unsecure.assign_permission(
_created_by text, _user_id bigint, _correlation_id text,
_user_group_id integer, _target_user_id bigint,
_perm_set_code text, _perm_code text,
_request_context jsonb default null, _tenant_id integer default 1
)
returns SETOF auth.permission_assignment
language plpgsql
as $$
declare
__assignment auth.permission_assignment;
begin
-- input validation via error.raise_*
if _target_user_id is null and _user_group_id is null then
perform error.raise_31001(); -- "either user group id or target user id must not be null"
end if;
-- the actual mutation
insert into auth.permission_assignment (...) values (...) returning * into __assignment;
-- cache invalidation (delegated, not inlined)
perform unsecure.invalidate_user_group_id_cache(_target_user_id, _tenant_id);
-- audit journal
perform public.create_journal_message_for_entity(
_created_by, _correlation_id, 50101, /* event_id */
jsonb_build_object('user', _target_user_id, 'permission', _perm_code),
_request_context, _tenant_id
);
return next __assignment;
end;
$$;
The trigger does the rest — trg_notify_permission_assignment fires after insert on auth.permission_assignment and calls unsecure.notify_permission_change(...), which wraps pg_notify('permission_changes', ...). The application's backend has a LISTEN permission_changes worker that resolves affected users via auth.notify_permission_users.
This example delegates to unsecure.* because assigning permissions is auth/authz work. For an ordinary business domain the worker lives in internal.* instead — the wrapper shape is identical (perform auth.has_permission(...) then delegate), only the target schema differs. See the calling rules.