An invariant is a fact that must remain true regardless of which application, migration, script, or administrator writes the data. If the database can express it directly, the database is usually the strongest enforcement point.
Use the simplest mechanism that fits
CREATE TABLE account (
account_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
balance numeric(12,2) NOT NULL CHECK (balance >= 0),
status text NOT NULL CHECK (status IN ('active', 'frozen'))
);
CREATE TABLE transfer (
transfer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
from_id bigint NOT NULL REFERENCES account(account_id),
to_id bigint NOT NULL REFERENCES account(account_id),
amount numeric(12,2) NOT NULL CHECK (amount > 0),
CHECK (from_id <> to_id)
);
The hierarchy is intentional:
- Type and
NOT NULLdefine basic shape. CHECKvalidates one row.UNIQUEand primary keys prevent duplicate identities.- Foreign keys protect references between tables.
- Triggers handle rules that cannot be expressed cleanly above.
In plain terms: use a lock before hiring a guard. Declarative constraints are visible, optimized, and automatically applied to every writer.
Constraints reject bad states
Application validation improves error messages but cannot replace database enforcement. Two requests can both pass an application-side “email is available” check and then race to insert. A unique constraint turns that race into one success and one explicit failure.
Foreign-key actions are business choices:
RESTRICTorNO ACTION: reject deletion while children exist.CASCADE: delete or update dependent rows too.SET NULL: preserve the child but remove the relationship.
Do not select an action merely to make deletion convenient. It defines what historical data survives.
Use a trigger when the rule crosses an event boundary
A trigger may be justified for an audit record that must exist for every update:
CREATE FUNCTION audit_account_change() RETURNS trigger AS $$
BEGIN
INSERT INTO account_audit(account_id, old_status, new_status, changed_at)
VALUES (OLD.account_id, OLD.status, NEW.status, current_timestamp);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER account_status_audit
AFTER UPDATE OF status ON account
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION audit_account_change();
The trigger is part of the same transaction. If the audit insert fails, the update fails too.
Why triggers become dangerous
Triggers are implicit: an apparently simple update can execute more writes, acquire more locks, or fail for a reason hidden from the caller. Trigger chains make order and recursion difficult to understand.
Avoid triggers for values that can be derived in a query, calls to external services, and long workflows. Prefer an explicit transaction or an outbox event for work that crosses the database boundary.
Test failure, not only success
For each rule, keep one statement that must fail:
INSERT INTO transfer(from_id, to_id, amount)
VALUES (10, 10, -5);
The exact constraint named in the error tells you whether enforcement is placed where intended.
Review card
- Put invariants at the lowest clear declarative level.
- Application validation and database constraints solve different problems.
- Foreign-key actions define data lifecycle.
- Triggers are transactional but implicit.
- Keep trigger work small, deterministic, and inside the database.
- Test expected rejection paths.
Invariant 是無論 application、migration、script 或管理者怎麼寫入,都必須成立的事實。只要資料庫能直接表達,它通常就是最有力的執行位置。
使用最簡單、足夠的機制
CREATE TABLE account (
account_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
balance numeric(12,2) NOT NULL CHECK (balance >= 0),
status text NOT NULL CHECK (status IN ('active', 'frozen'))
);
CREATE TABLE transfer (
transfer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
from_id bigint NOT NULL REFERENCES account(account_id),
to_id bigint NOT NULL REFERENCES account(account_id),
amount numeric(12,2) NOT NULL CHECK (amount > 0),
CHECK (from_id <> to_id)
);
這個順序是有意義的:
- Type 與
NOT NULL定義基本形狀。 CHECK驗證單一 row。UNIQUE與 primary key 阻止重複 identity。- Foreign key 保護 table 之間的 reference。
- 前面都無法清楚表達時,才考慮 trigger。
白話來說: 能裝鎖就先不要請警衛。Declarative constraint 看得見、資料庫懂得最佳化,而且每個寫入者都會自動受到約束。
Constraint 拒絕不合法狀態
Application validation 能提供較好的錯誤訊息,卻不能取代資料庫 enforcement。兩個 request 可以同時通過「email 尚未使用」的檢查,再一起 insert。Unique constraint 會讓其中一個成功,另一個得到明確失敗。
Foreign-key action 是商業決策:
RESTRICT或NO ACTION:還有 child 時拒絕刪除。CASCADE:child 也一起刪除或更新。SET NULL:保留 child,但移除 relationship。
不要只因為刪除方便就選 action;它實際定義哪些歷史資料會留下。
規則跨越資料事件時才用 trigger
如果每次狀態修改都必須留下 audit,trigger 可能合理:
CREATE FUNCTION audit_account_change() RETURNS trigger AS $$
BEGIN
INSERT INTO account_audit(account_id, old_status, new_status, changed_at)
VALUES (OLD.account_id, OLD.status, NEW.status, current_timestamp);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER account_status_audit
AFTER UPDATE OF status ON account
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION audit_account_change();
Trigger 和原本 update 位於同一個 transaction;audit insert 失敗,update 也會一起失敗。
Trigger 危險在哪裡
Trigger 是 implicit behavior。一個看似簡單的 update,背後可能多做寫入、取得更多 lock,或因呼叫者看不到的原因失敗。Trigger chain 還會讓順序與 recursion 變得難懂。
可由 query 推導的值、外部 service call 和長 workflow 都不適合 trigger。跨出資料庫的工作,應考慮明確 transaction 或 outbox event。
不只測成功,也測拒絕
每條規則都保留一個必須失敗的 statement:
INSERT INTO transfer(from_id, to_id, amount)
VALUES (10, 10, -5);
錯誤訊息指出哪個 constraint 拒絕資料,也能驗證 enforcement 是否放在預期位置。
複習卡
- 把 invariant 放在最低且清楚的 declarative 層級。
- Application validation 與 database constraint 解決不同問題。
- Foreign-key action 定義資料生命週期。
- Trigger 具有 transaction 保證,但行為隱含。
- Trigger 工作保持小、deterministic,而且不跨出資料庫。
- 測試預期的 rejection path。