# ADR-0005. Transactional outbox вместо event sourcing

- Статус: принято
- Дата: 2026-09-30

## Контекст

Нужны вебхуки, триггерные коммуникации и поток данных в аналитику без потери событий. Неизменяемый
леджер уже даёт полную историю, поэтому event sourcing добавил бы сложность без новой ценности
(опыт Open Loyalty и предупреждения Фаулера о CQRS).

## Решение

- Доменные события пишутся в таблицу `outbox_events` в той же транзакции, что и изменения данных.
- Relay-воркер (системная роль БД) выбирает неопубликованные события `FOR UPDATE SKIP LOCKED`
  пачками, передаёт обработчикам (вебхуки, коммуникации, аналитика) и помечает опубликованными.
- Доставка — «как минимум один раз»; все потребители идемпотентны по `event_id`.
- У события есть тип, версия схемы, `tenant_id`, агрегат, время возникновения и полезная нагрузка
  без лишних ПДн.
- Вебхуки подписываются по спецификации Standard Webhooks (HMAC-SHA256).
- `dispatch()->afterCommit()` Laravel не заменяет outbox: при падении процесса между коммитом
  и отправкой событие терялось бы.

## Реализация (этап 5a)

- **Источник событий — доменные события модулей.** Модули-источники не знают об outbox: они публикуют
  доменные события (`Modules\<Module>\Events`) внутри транзакции изменения, а модуль `outbox` подписан на
  них и пишет интеграционное событие в `outbox_events` той же транзакцией. Откат изменения откатывает и
  событие. Модули-источники не зависят от outbox; outbox зависит от их публичных событий.
- **Каталог** интеграционных событий и версии их данных — `Modules\Outbox\Contracts\EventTypes`,
  описание для интеграторов — `docs/api/webhooks.md`. Данные — идентификаторы и суммы, без ПДн.
- **Relay** (`outbox:relay`, каждые 5 секунд) читает ожидающие события всех тенантов системной ролью
  `FOR UPDATE SKIP LOCKED` и передаёт каждое потребителям (`OutboxConsumer`, тег `outbox.consumers`) в
  тенанте события. Событие помечается опубликованным, когда его приняли все потребители; сбой оставляет
  его на следующий запуск (`attempts`, `last_error`), после 10 неудач relay от него отказывается
  (`failed_at`). Опубликованные события удаляются через 30 дней (`outbox:prune`).
- **Вебхуки** (модуль `webhooks`) — первый потребитель. Он создаёт по доставке на каждый подписанный
  активный эндпоинт тенанта с замороженным телом (`insertOrIgnore` по эндпоинту и событию — повтор события
  не создаёт второй доставки). `webhooks:deliver` (каждые 5 секунд) захватывает доставки коротким lease в
  отдельной транзакции и отправляет вне её: медленный получатель не держит блокировок. Подпись — Standard
  Webhooks, повторы — по расписанию до ~2,6 суток, `410 Gone` отключает эндпоинт, каждая попытка
  журналируется. Защита от SSRF: только https и публичные адреса, соединение — с адресом, прошедшим
  проверку DNS, без редиректов.

## Последствия

- Нет потери событий при сбоях; порядок доставки не гарантируется — потребители должны это учитывать.
- Когда появятся внешние потребители или сервисы на других языках, relay сможет публиковать в Kafka
  без изменения модулей-источников.
