Бизнес-логика в БД — антипаттерн в современной разработке. Эта статья объясняет почему, и где триггеры ещё имеют место.

Правила пронумерованы кодами 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 — Триггер оправдан, когда:

  1. Compliance-требование «не пропустить ни одно изменение» (медицина, банк, регулируемая отрасль). Audit-триггер гарантирует.

  2. Существующая система с PG как «источником истины», через который ходят несколько приложений. Триггер — единственный способ обеспечить инвариант для всех.

  3. Денормализация, где гарантия мгновенной консистентности важнее простоты. Точный count после COMMIT, а не «когда-то догонит».

  4. Защита от ручного 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 source 1M строк → триггер выполнится 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-типы — EXCLUDE constraint вместо триггера-валидатора.