Outbox (Transactional Outbox)
Событие о бизнес-изменении записывается в ту же базу и в той же транзакции, что и само изменение, а в брокер его отправляет отдельный процесс: данные и событие не расходятся, даже если брокер лежит.
Проблема
Магазин сохраняет заказ в базу и публикует событие OrderPlaced в брокер сообщений: по нему склад резервирует товар, а почта шлёт письмо. Это две записи в две разные системы, и общей транзакции у них нет. DirectOrderService делает именно так: коммитит заказ, потом вызывает брокер. Если брокер в этот момент недоступен, заказ уже сохранён, а событие потеряно: склад о заказе не узнает никогда. Поменять порядок не помогает: опубликовать событие, а потом упасть на коммите ещё хуже, тогда склад резервирует товар под несуществующий заказ.
Это проблема двойной записи (dual write). Ретраи вокруг вызова брокера её не решают: процесс может упасть между коммитом и публикацией, и ретраить будет некому.
Решение
Не писать в две системы сразу. OrderService в одной транзакции вставляет заказ в таблицу orders и сообщение в таблицу outbox той же базы. База гарантирует, что сохранится либо и то и другое, либо ничего.
Публикацию делает отдельный отправитель (relay) OutboxRelay: он читает неотправленные сообщения, публикует их в брокер и помечает отправленными. Если брокер лежит, сообщения ждут в таблице и уйдут на следующем проходе. Отправитель не знает о заказах: он переносит строки из таблицы в брокер.
Структура
Отправитель обходит неотправленные сообщения по порядку id. Сообщение помечается отправленным только после успешной публикации; если брокер отказал, обход останавливается, и всё, что не ушло, остаётся в таблице до следующего вызова publishPending. Отправленные повторно не публикуются. В проекте отправитель запускают по таймеру или в фоновом процессе; здесь его вызывает тест.
Как это собирается
В приложении две точки входа над одной базой. Обработчик POST /orders получает OrderService и вызывает placeOrder: заказ и сообщение OrderPlaced пишутся одной транзакцией, и ответ 201 зависит только от базы. Фоновый процесс получает OutboxRelay с той же базой и клиентом брокера и по таймеру вызывает publishPending. Связывает их корень приложения: создаёт Database и InMemoryBroker (в проекте - пул соединений и клиент брокера) и раздаёт обоим.
В примере брокер лежит, пока покупатели оформляют два заказа: оба получают 201. Первый проход отправителя ничего не отправляет, и два сообщения ждут в таблице. Брокер поднялся - второй проход публикует оба по порядку, третий не находит ничего и повторно не шлёт. Путь вызова: обработчик -> сервис -> транзакция в базе, а отдельно: таймер -> отправитель -> outbox -> брокер.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Брокер за интерфейсом MessageBroker с одним методом publish. Подделка InMemoryBroker копит опубликованные сообщения в списке и по флагу down бросает BrokerUnavailable, как сеть при недоступном брокере. Хранилище Database — две таблицы в памяти; транзакция снимает их копию и восстанавливает при исключении, так что откат настоящий, хоть и без SQL. Полезная нагрузка — JSON заказа, суммы в центах.
| Язык | Особенность |
|---|---|
| PHP | transaction(Closure) с шаблонным типом для PHPStan; копия массивов при входе даёт откат бесплатно (copy-on-write) |
| Go | Transaction(func(tx) error) откатывает по возвращённой ошибке; PublishPending возвращает число и error с %w, тест проверяет errors.Is |
| Python | Транзакция — контекстный менеджер with db.transaction(): на @contextmanager; брокер — Protocol |
| TypeScript | transaction<T>(work: () => T): T; отправитель ловит только BrokerUnavailable, остальные ошибки пробрасывает |
Запуск: make test T=patterns/enterprise/outbox
Плюсы и минусы
Тесты одинаковы во всех языках и проверяют четыре случая: двойная запись при лежащем брокере сохраняет заказ, но теряет событие; с outbox тот же сбой оставляет сообщение в таблице, и когда брокер возвращается, отправитель публикует OrderPlaced {"orderId":1,"totalCents":2500}; исключение внутри транзакции откатывает и заказ, и сообщение; второй проход отправителя не публикует уже отправленное.
- ✅ Событие не теряется: оно сохранено вместе с данными и уйдёт, когда брокер станет доступен.
- ✅ Сервис заказов не зависит от доступности брокера: заказ принимается, даже если брокер лежит.
- ✅ Порядок событий задаётся порядком строк в таблице.
- ❌ Доставка «как минимум один раз»: если отправитель упадёт после публикации, но до отметки, сообщение уйдёт повторно. Потребителю нужна идемпотентность — это разбирает практика.
- ❌ Событие доходит с задержкой: между коммитом и публикацией проходит время до следующего прохода отправителя.
- ❌ Лишняя таблица, фоновый процесс и очистка отправленных строк.
Когда не применять
- Событие не обязано дойти (метрика, подсказка в интерфейсе): хватит прямой публикации или внутрипроцессного наблюдателя.
- Получатели живут в том же процессе и могут отработать в той же транзакции: событие обрабатывается сразу, брокер не нужен.
- Состояние уже хранится как поток событий (event sourcing): запись события и есть изменение, отдельная таблица outbox не нужна.
- Получатель вызывается синхронно и должен ответить сразу: это не событие, а запрос.
Отличия между языками
- PHP — массивы копируются при присваивании, поэтому снимок таблиц для отката — просто
[$this->orders, $this->outbox]. ЛовитсяThrowable: откат нужен при любом сбое, а исключение пробрасывается дальше. В Doctrine то же делаютwrapInTransactionи запись outbox-сущности в одномflush. - Go — транзакция принимает функцию и откатывается по возвращённой ошибке, как обёртки над
database/sql.maps.Cloneиslices.Cloneдают снимок. Отправитель возвращает ошибку брокера вызывающему: решение «попробовать позже» принимает тот, кто его запустил. - Python —
@contextmanagerпревращает транзакцию в блокwith: исключение внутри блока попадает вexceptгенератора, тот откатывает и пробрасывает дальше. Так же устроеныwith connection:вsqlite3иsession.begin()в SQLAlchemy. - TypeScript —
transactionпринимает функцию и возвращает её результат;catchполучаетunknown, поэтому отправитель проверяетinstanceof BrokerUnavailableи пробрасывает остальное. С асинхронным драйвером сигнатура станетtransaction<T>(work: () => Promise<T>): Promise<T>, суть та же.
Практика
- Учебный отправитель публикует всё за один проход, а брокер либо работает, либо нет. Практика: надёжный отправитель outbox показывает, что бывает в жизни: брокер падает на N-й попытке, подтверждение теряется после доставки и получатель видит дубль, сообщения одного заказа должны прийти по порядку, а отправитель берёт их пачками. Получатель там идемпотентен по
idсообщения. - Брокер - своя система с простыми отказами. Внешний API отвечает сложнее: Telegram просит подождать
retry_afterсекунд, навсегда отказывает в чатах, где бота заблокировали, и не отсекает дубли сам. Практика: уведомления клиентам в Telegram показывает уведомление о заказе в одной транзакции с заказом, отправку пачками, паузу на 429, мёртвые чаты и ключ идемпотентности, из-за которого повтор заказа не даёт второго сообщения.
Связанные темы
- Практика: надёжный отправитель outbox — ретраи, дубли, идемпотентный получатель, порядок по агрегату, пачки
- Практика: уведомления клиентам в Telegram - Bot API за outbox: пачки, пауза на 429 по retry_after, мёртвые чаты, ключ идемпотентности
- Доменное событие (Domain Event) — что публиковать: outbox отвечает за то, чтобы доменное событие дошло до других сервисов
- Единица работы (Unit of Work) — собирает изменения в одну транзакцию; строку outbox удобно записывать в том же коммите
- Наблюдатель (Observer) — рассылка событий внутри процесса, без гарантий доставки
- Практика Наблюдателя: доменные события заказа — диспетчер событий в памяти; outbox — следующий шаг, когда событие нельзя потерять