Бизнес-логика в БД — антипаттерн в современной разработке. Эта статья объясняет почему, и где триггеры ещё имеют место.
Правила пронумерованы кодами PG-TR-NNN.
1. Базовая позиция
PG-TR-001 — Бизнес-логика живёт в коде приложения, не в БД
В коде она:
- Тестируется обычными unit-тестами.
- Видна в IDE с навигацией.
- Версионируется в git вместе с фичей.
- Откатывается релизом приложения, не миграцией.
- Не зависит от движка БД (можно поменять PG на что-то).
PG-TR-002 — Логика в триггерах:
- Тестируется через интеграционные тесты с БД.
- Не видна в IDE Java/Spring (отдельный SQL).
- Версионируется через миграции (накат — отдельный релиз).
- Откатывается миграцией (часто болезненно).
- Привязана к PG (триггеры plpgsql переписать на другой движок — большая работа).
PG-TR-003 — Это не значит «триггеры зло»
— есть редкие сценарии, где они оправданы (см. §3). Но default — без них.
2. Что обычно делают триггерами и почему НЕ надо
created_at / updated_at
PG-TR-010 — Триггер для updated_at = now() — антипаттерн
Используй DEFAULT now() в DDL и обновляй из приложения.
-- плохо: триггер
CREATE TRIGGER tr_set_updated_at
BEFORE UPDATE ON order_doc FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- хорошо: DDL + явный UPDATE
CREATE TABLE order_doc (
...
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- в коде:
UPDATE order_doc SET status = ?, updated_at = now() WHERE id = ?;
PG-TR-011 — Триггер скрывает поведение
Разработчик пишет UPDATE без updated_at, ожидает что он останется старым — а триггер тихо обновил. Дебажить приходится.
Денормализация (агрегаты, счётчики)
PG-TR-020 — Триггер AFTER INSERT/DELETE для обновления count/sum — терпимо, но не идеально
-- терпимо: триггер обновляет post_count в forum
CREATE TRIGGER tr_update_post_count
AFTER INSERT OR DELETE ON post
FOR EACH ROW EXECUTE FUNCTION update_forum_post_count();
Альтернативы:
- Materialized view с refresh — проще, видно явно.
- Read Model в коде через event-handler — гибкость, тестируемость.
- Расчёт on-the-fly при чтении (
SELECT count(*) FROM post WHERE ...) — если данных мало.
Триггер удобен для мгновенной консистентности (счётчик всегда точный после COMMIT). Но цена — невидимая логика.
Audit-trail
PG-TR-030 — Триггер для audit-таблицы — оправдан, если нужна гарантия «никто не пропустит»
CREATE TRIGGER tr_order_doc_audit
AFTER INSERT OR UPDATE OR DELETE ON order_doc
FOR EACH ROW EXECUTE FUNCTION log_to_audit();
Альтернативы:
- Outbox + код — каждое изменение в коде явно создаёт audit-event. Гибче, но можно забыть.
- Logical replication / CDC — внешний сервис ловит WAL, пишет в audit-store. Подходит для compliance.
- Триггер — гарантия покрытия, не зависит от того, что разработчик не забыл.
Для compliance-чувствительных систем (банки, медицина) триггер — разумный выбор.
Валидация инвариантов
PG-TR-040 — CHECK constraint > триггер
Простые инварианты — через CHECK. Сложные (зависящие от других строк) — в коде с SELECT FOR UPDATE.
-- хорошо: CHECK
ALTER TABLE order_doc ADD CONSTRAINT ck_order_total_positive CHECK (total_amount >= 0);
-- хорошо: EXCLUDE для непересечения интервалов
EXCLUDE USING gist (room_id WITH =, period WITH &&)
-- терпимо: триггер для сложного инварианта
CREATE TRIGGER tr_doctor_on_call_check
BEFORE UPDATE ON doctor FOR EACH ROW EXECUTE FUNCTION check_at_least_one_oncall();
PG-TR-041 — Если инвариант между несколькими строками или таблицами — лучше код с FOR UPDATE и SELECT count
Триггер FOR EACH ROW плохо видит общую картину.
3. Когда триггер действительно оправдан
PG-TR-050 — Триггер оправдан, когда:
-
Compliance-требование «не пропустить ни одно изменение» (медицина, банк, регулируемая отрасль). Audit-триггер гарантирует.
-
Существующая система с PG как «источником истины», через который ходят несколько приложений. Триггер — единственный способ обеспечить инвариант для всех.
-
Денормализация, где гарантия мгновенной консистентности важнее простоты. Точный
countпосле COMMIT, а не «когда-то догонит». -
Защита от ручного UPDATE через psql — devops иногда лезут руками, триггер защитит схему.
4. Хранимые процедуры (FUNCTION / PROCEDURE)
PG-TR-060 — Хранимые процедуры в современном Java/Spring — почти никогда не нужны
Что обычно делают процедурами:
- Сложная транзакция → есть
@Transactionalв Spring. - Bulk-логика → есть
INSERT INTO ... SELECT ...без процедуры. - Производительность → редко выигрывает у хорошо написанного app-кода с jOOQ batch.
PG-TR-061 — Где процедура оправдана:
- Очень тяжёлые SQL-операции, где cost network round-trip между PG и app существенен (миллионы строк, сложные join'ы).
- ETL, который читает гигабайты, агрегирует, пишет — а app-сервер это не его масштаб.
PG-TR-062 — PG procedures (PG11+) умеют commit/rollback внутри
в отличие от functions. Полезно для batch-job'ов.
CREATE PROCEDURE archive_old_orders() LANGUAGE plpgsql AS $$
DECLARE rows_affected integer := 1;
BEGIN
WHILE rows_affected > 0 LOOP
WITH deleted AS (
DELETE FROM order_doc
WHERE created_at < now() - interval '1 year'
RETURNING *
)
INSERT INTO order_archive SELECT * FROM deleted;
GET DIAGNOSTICS rows_affected = ROW_COUNT;
COMMIT;
END LOOP;
END;
$$;
PG-TR-063 — Альтернатива — @Scheduled + батчированный код в Java
Та же логика, тестируется обычно, видно в репо.
5. Стоимость триггеров
PG-TR-070 — Триггер увеличивает время записи
BEFORE— выполняется до записи, может изменить значения.AFTER— выполняется после, может только читать.FOR EACH ROW— на каждую строку (медленно для bulk).FOR EACH STATEMENT— один раз на statement (быстрее).
PG-TR-071 — На bulk операциях FOR EACH ROW особенно дорог
INSERT INTO target SELECT FROM source1M строк → триггер выполнится 1M раз.
PG-TR-072 — Триггеры могут стать причиной deadlock
Если триггер обновляет другую таблицу, порядок локов меняется недетерминированно.
6. Дебаг триггеров
PG-TR-080 — Триггер — невидимая логика. Чтобы найти, кто что меняет:
-- найди все триггеры на таблице
SELECT trigger_name, event_manipulation, action_statement
FROM information_schema.triggers
WHERE event_object_table = 'order_doc';
-- посмотри тело функции
SELECT prosrc FROM pg_proc WHERE proname = 'set_updated_at';
PG-TR-081 — Лог в триггере для дебага:
CREATE OR REPLACE FUNCTION debug_trigger()
RETURNS trigger AS $$
BEGIN
RAISE NOTICE 'TRIGGER fired: % on table %', TG_OP, TG_TABLE_NAME;
-- ...
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
PG-TR-082 — Тесты триггеров — через интеграционные тесты в Spring
(Testcontainers PostgreSQL). Логика проверяется через INSERT/UPDATE и assertion на итоговое состояние.
7. Антипаттерны
PG-TR-090 Триггер для updated_at — лучше явно в коде.
PG-TR-091 Триггер для бизнес-валидации (например, if order.total < 1000 then disallow) — должно быть в Java-сервисе.
PG-TR-092 Триггер, отправляющий NOTIFY или вызывающий внешний HTTP — связь приложения с триггером скрыта.
PG-TR-093 Сложная цепочка триггеров (A -> trigger -> B -> trigger -> C) — отлаживать ад.
PG-TR-094 Stored procedure для логики, которую можно сделать в коде — лишние миграции, нет тестов.
PG-TR-095 Триггер BEFORE INSERT модифицирующий значение в NEW.* без явного документирования — разработчик удивляется, что INSERT записал не то, что отправил.
Чек-лист на ревью
- [ ] Бизнес-логика в коде, не в триггерах.
- [ ]
updated_atобновляется явно из кода или черезDEFAULT now(), не триггером. - [ ] Денормализованные счётчики — через MV, Read Model или код, а не триггер (если только не критична мгновенная консистентность).
- [ ] Audit-триггер — только для compliance-требований.
- [ ] CHECK constraint предпочтителен триггеру для простых инвариантов.
- [ ] EXCLUDE constraint для непересечений, не триггер.
- [ ] Хранимые процедуры — только для тяжёлых ETL с большим IO между PG и app.
- [ ] Все триггеры задокументированы в коде (комментарий в миграции что и зачем).
- [ ] Тесты на триггерную логику есть в интеграционных тестах.
Связанные
- Уровень 2 UCP — Read Model в коде вместо триггера-агрегатора.
- Materialized views — альтернатива триггеру-денормализатору.
- Массивы и range-типы —
EXCLUDEconstraint вместо триггера-валидатора.